AI Agents — Tools declaration
An agent is worth nothing without the ability to call your systems. GoSendAPI lets you declare tools as HTTP contracts, not as code. You already have the code — your ERP, your API, your booking system. What we need is the contract that lets the agent reach it safely.
This guide covers the tool declaration format only. For agent creation, versioning, binding scopes, secrets and reactivation, see the Agents API overview.
The five blocks
A tool declaration has five blocks. Each one has a clear job:
description: >
Search for available appointment slots.
Call when the patient asks for a booking.
input_schema:
type: object
properties:
especialidad: { type: string }
desde: { type: string, format: date }
required: [desde]
request:
method: GET
url: "{{binding.base_url}}/disponibilidad"
query:
especialidad: "{{input.especialidad}}"
fecha: "{{input.desde}}"
auth:
type: bearer
token: "{{binding.secret.api_key}}"
timeout_ms: 8000
response:
pick: "data[].{fecha, hora, profesional}"
max_items: 10
errors:
negocio: [404, 422]
infra: [401, 403, 429, 500, 502, 503, 504]
semantic:
TURNO_OCUPADO: "Offer another time slot"
OS_NO_VALIDA: "Ask about a different insurance provider"| Block | Purpose |
|---|---|
description | What the model sees when deciding whether to call the tool |
input_schema | JSON Schema (draft-07) — validated with Ajv before the request goes out |
request | HTTP contract to your API — the only part that touches the network |
response | Allowlist extraction of the fields the model needs |
errors | Classification of failure modes: business (return to model) vs infra (escalate) |
Plus one optional top-level field:
| Field | Purpose |
|---|---|
terminates_thread | true for milestone tools that close a conversation (booking a slot, cancelling a slot, submitting a form). After a successful call the runtime clears AgentThread.messages so the next patient turn starts on a clean slate. Default false. See Milestone tools & auto-reset. |
The three namespaces
Every placeholder in the declaration comes from exactly one of three namespaces. Mixing them is the security bug of this design.
| Namespace | Source | Trust |
|---|---|---|
{{input.*}} | Filled by the LLM | Untrusted (model input = internet input) |
{{binding.*}} | Deployment config: base_url, encrypted secrets, per-tenant params | Trusted |
{{context.*}} | Runtime the model cannot forge: customer_id, contact_phone, conversation_id | Trusted |
The absolute floor
{{input.*}} CANNOT appear in the URL host/protocol, the query string of request.url, or in request.auth.token, .key, .password.
If it could, a prompt injection would turn GoSendAPI into an SSRF proxy holding your credentials. The declaration is rejected at creation time and the runtime re-checks it as defense in depth.
Where {{input.*}} is allowed:
request.urlpath segments — REST-friendly (/turnos/{{input.id}},/profesionales/{{input.pid}}/obras-sociales). The runtime sanitizes each value before splicing it in: any/,\,?,#,%,:,..or control chars in the substituted value throwsToolSecurityError— the model cannot break out of the path segment.request.query— search parameters filled by the model. Use thequeryobject (not the URL query string).request.body— payload fields for POST/PUT/PATCH.description— for prompt engineering, though most people don’t do this.
Where the trusted namespaces go:
{{binding.base_url}}— always at the start ofrequest.url.{{binding.secret.<name>}}— insiderequest.auth.token/.key/.password. Encrypted at rest with AES-256-GCM.{{context.contact_phone}}— safe to use in a URL path segment like/contacts/{{context.contact_phone}}, because the model cannot make it up.
Response extraction — allowlist, not blocklist
The pick field says exactly what comes back to the model. Everything else is dropped. Three reasons this is a rule and not a suggestion:
Tokens
Every unused field is paid on every loop turn, because the tool result stays in the conversation history the model re-reads.
Data leaks
Your ERP may return other patients’ data in the same payload. The allowlist prevents that by design.
Compliance
Under Argentine Ley 25.326 and GDPR, sending unnecessary health data to an LLM is personal data traveling without purpose. The pick is a compliance control, not a performance optimization.
Pick syntax
"data" Direct path — returns response.data
"paciente.nombre" Nested dot-notation
"data.turnos[].fecha" Array projection — one field per item
"data.turnos[].{fecha, hora}" Shape projection — an object per item
"*" Return the whole response (rare)max_items caps arrays at N (default 10). Set it explicitly for lists — the model does not need to see more than what fits its decision.
Error classification
Every HTTP response is classified into one of four outcomes:
| Class | Effect | Examples |
|---|---|---|
| ok (2xx) | Extract with pick, return to model | 200 OK with data |
| negocio (declared 4xx) | Return to model with a business hint | 404 SIN_COINCIDENCIAS, 422 OS_NO_VALIDA |
semantic (4xx with declared status_code) | Return to model with a specific hint | 409 { status_code: TURNO_OCUPADO } |
| infra | Cut the loop, escalate to a human | 401 INVALID_API_KEY, 403 API_INACTIVE, 429, all 5xx |
4xx = negocio has one critical exception. 401, 403, and 429 are 4xx but are broken configuration, not business conditions. Never let them return to the model — the model will confidently tell your patient “your account terms have not been accepted.” The default runtime treats these as infra regardless of what you declared.
The semantic map lets you turn body-level codes into specific hints for the model:
errors:
negocio: [404, 409, 422]
infra: [401, 403, 429, 500, 502, 503, 504]
semantic:
TURNO_OCUPADO: "Slot is taken — offer another time"
OS_NO_VALIDA: "Ask the patient about a different insurance"If the body has {"status_code": "TURNO_OCUPADO", ...}, the model sees “Slot is taken — offer another time” instead of a generic “409 error.”
One tool = one request
If something needs two calls, it is an endpoint missing from your API, not a coordination problem the declaration should solve.
Concrete example: rescheduling an appointment as DELETE /turnos/{id} + POST /turnos would create a window where the old slot is already gone and the new one might fail. The rule keeps atomicity where it belongs (your system, in a transaction) and prevents building a mini-orchestrator inside the declaration.
Async tools (202 + callback + parked run) are on the V2 roadmap. If you have a workflow that takes longer than 30 seconds or requires human approval upstream, tell us — we want it prioritized based on real cases.
Tool catalog is per-tenant
Tools live in a catalog scoped to your tenant. An AgentVersion references tools by slug — never inline. Reasons:
- Sharing: your booking agent, your reminder cron, and your billing bot all need
identify_patient. Inline means three copies that drift. - Kill switch: disable a tool (
enabled=false) without deleting it. All agents referencing it stop calling it immediately. - Versioning: change the declaration once, all bindings pick it up on the next run.
Debugging: error messages
The engine produces errors targeted at the operator writing the declaration:
ToolSecurityError: request.url contains {{input.*}} — the input namespace is
untrusted (filled by the model) and cannot control the HTTP target. Use
{{binding.base_url}} + a static path.
See https://docs.gosendapi.com/guides/agents-tools#securityToolDeclarationError: request.timeout_ms must be between 100 and 30000.
Got: 40000.
See https://docs.gosendapi.com/guides/agents-tools#declaration-request-timeoutToolResponseExtractError: Response.pick "data.turnos[].fecha" expects an
array at path "data.turnos" but got string. Check the declaration.
See https://docs.gosendapi.com/guides/agents-tools#response-pickThe runtime never surfaces these to the model — the LLM sees a generic hint (“this tool has a configuration problem, escalating to a human”) while the internal error goes to logs and to the AgentRun.errorMessage field.
Complete working example
A minimal search tool that hits your API and returns three fields per slot:
{
"slug": "gs.buscar_disponibilidad",
"name": "Buscar disponibilidad",
"description": "Busca turnos disponibles. Requiere paciente identificado.",
"declaration": {
"description": "Busca turnos disponibles en la agenda del centro. Devuelve hasta 10 slots.",
"input_schema": {
"type": "object",
"properties": {
"especialidad": { "type": "string", "description": "Nombre de la especialidad" },
"desde": { "type": "string", "format": "date" }
},
"required": ["desde"]
},
"request": {
"method": "GET",
"url": "{{binding.base_url}}/api/v1/disponibilidad",
"query": {
"especialidad": "{{input.especialidad}}",
"desde": "{{input.desde}}"
},
"auth": {
"type": "bearer",
"token": "{{binding.secret.api_key}}"
},
"timeout_ms": 8000
},
"response": {
"pick": "data[].{fecha, hora, profesional_nombre}",
"max_items": 10
},
"errors": {
"negocio": [404, 422],
"infra": [401, 403, 429, 500, 502, 503, 504],
"semantic": {
"SIN_COINCIDENCIAS": "No slots available in the requested window — suggest a wider date range"
}
}
}
}Auth: two-step JWT flow
If your API uses a “token endpoint + Bearer token” pattern (Laravel Sanctum, Django SimpleJWT, OAuth2 client_credentials, custom), use auth.type: "jwt_from_token_endpoint". The engine will call the token endpoint first, cache the token by (tenantId, toolSlug) for expires_in_sec seconds, then attach Authorization: Bearer <token> to the main request.
{
"auth": {
"type": "jwt_from_token_endpoint",
"token_url": "{{binding.params.base_url}}/v1/auth/token",
"token_method": "POST",
"token_auth": {
"type": "api_key_header",
"header": "X-Api-Key",
"key": "{{binding.secret.api_key}}"
},
"token_response_path": "token",
"expires_in_sec": 3300
}
}Fields:
token_url— endpoint that issues the token. Interpolable.token_method— HTTP method, defaultPOST.token_body(optional) — body for the pre-request. Interpolable. Useful for OAuth2 ({"grant_type": "client_credentials", "client_id": "…", "client_secret": "…"}) or custom body-based credentials.token_auth(optional) — auth for the pre-request itself. Uses the same 4 single-step types (none,bearer,api_key_header,basic). Cannot be recursive withjwt_from_token_endpoint.token_response_path— dot-notation path in the JSON response where the token lives. Examples:"token","data.access_token","auth.jwt.value". No arrays or expressions.expires_in_sec— cache TTL in seconds. Must be less than the actual token TTL to avoid races. If your JWT lasts 1 hour, set3300(55 min). Range: 60 to 86400 (1 min to 24 h).
The token cache is shared across runs and thread-scoped invalidations happen automatically when the main request returns 401 (token revoked before TTL).
Why not just use api_key_header directly? Some APIs require the JWT flow — the API key alone is rejected. Using jwt_from_token_endpoint lets you integrate them without asking the API owner to add a bypass.
Milestone tools & auto-reset
Some tool calls close a topic: a slot is booked, a slot is cancelled, a form is submitted. After that, whatever the patient sends next is almost always a new topic — a different booking, a different question — not a continuation.
If the agent kept the whole message history in memory, that history would act as an implicit few-shot — the model would replay old patterns even after you updated your system prompt, and every subsequent run would carry a growing token bill.
Mark the tool with terminates_thread: true and the runtime clears AgentThread.messages right after a successful call:
description: Books a new appointment.
input_schema: ...
request: ...
response: ...
errors:
negocio: [409, 422]
infra: [401, 500, 502, 503, 504]
terminates_thread: true # ← thisWhat happens on a milestone call (ok=true):
- The current run completes and its reply is sent to the patient.
AgentThread.messagesis cleared to[].- An
AgentEvent thread.messages_auto_resetis emitted withproperties.milestone_toolfor audit. - The next patient message opens a brand new turn — same system prompt, no prior context, fresh token budget.
Which tools deserve terminates_thread: true:
- ✅
crear_turno,cancelar_turno,reprogramar_turno— irreversible business actions. - ✅ Ticket submission, form completion, payment confirmation.
- ❌ Lookup/informational tools (
buscar_disponibilidad,list_professionals,get_pricing) — they are steps of a flow, not closures. - ❌ Tools that always succeed regardless of downstream success — you want the failure case to keep the history so the patient can retry.
Manual reset is also available: the portal has a “Reset conversation” button per thread — useful when iterating on the system prompt (the previous history poisons the new prompt’s behavior).
What is not implemented yet
This is Phase 1.a of the AI Agents module — the tool engine only. Coming next:
- LLM providers — Anthropic, OpenAI, Google, OpenRouter (F1.b).
- Runtime loop + queue — the piece that actually invokes the model with your tools attached (F1.c–d).
- Guardrails — the input floors described in the spec (F1.e).
- Public CRUD API — endpoints to create/version/deploy Agents, AgentVersions, AgentBindings, and AgentTools from your own tooling (F1.i).
- MCP client — autodiscover tools from your existing MCP servers (F1.k, tentative).
Watch the changelog for updates.