AI Agents — API overview
Everything you can do from the portal’s AI Agents tab is also exposed as a public REST API under /v1. Use it to provision agents from your own dashboards, run migrations, wire CI, or build multi-tenant products where each of your customers gets their own agent without a human touching the portal.
This guide is the mental model. For the exhaustive request/response schemas of every endpoint, see the interactive API Reference — it is auto-generated from the production NestJS source and always up to date.
The five resources
| Resource | What it is | Immutable? |
|---|---|---|
| Agent | The stable definition: name + LLM provider + model + description. One per “product” you build (e.g. booking assistant, support triage). | No |
| AgentTool | An HTTP contract to one of your endpoints — the primitive the agent uses to talk to your API. Scoped per tenant, referenced by slug. See tools declaration. | Yes (edit = new record) |
| AgentVersion | A snapshot: system prompt + selected tools + guardrails config. Immutable — every change creates a new version. Lets you roll v4 to 5% of pilots while the rest of production stays on v3, no infra needed. | ✅ Yes |
| AgentBinding | The deploy: assigns an AgentVersion to a scope (tenant / customer / phone_number) with per-client params, rules and encrypted secrets. Precedence when multiple match: phone_number > customer > tenant. | No |
| AgentThread | 1:1 with Conversation. Holds the message history plus explicit muting state (muted_at + muted_reason) that the guardrails floor writes to. See thread reactivation. | No |
Lifecycle
┌──────────┐ ┌───────────┐ ┌───────────────┐ ┌───────────────┐
│ Agent │──▶│ AgentTool │──▶│ AgentVersion │──▶│ AgentBinding │
│ (name, │ │ (HTTP │ │ (prompt + │ │ (scope + │
│ model) │ │ contract)│ │ tools + gr.) │ │ secrets) │
└──────────┘ └───────────┘ └───────────────┘ └───────────────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ AgentThread │
│ │ (per conv, │
│ │ auto-created)│
│ └──────────────┘
│
└─ Roll a new version = new record,
existing bindings unchanged.Runtime is transparent: when a message hits a phone_number that has a matching active AgentBinding, the runtime resolves the version, hydrates tools + secrets, runs the LLM loop, calls your endpoints when the model asks for them, and replies as source=AUTO_AGENT_PLATFORM. You never write orchestration code.
Quickstart — provision an agent end-to-end
Every request needs X-API-Key: <your key> and (for admin-only endpoints) X-Admin-Key. Never commit either. See rate limits and error handling.
Create an Agent
curl -X POST https://cloud.gosendapi.com/v1/agents \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Booking assistant",
"provider": "openai",
"model": "gpt-4o-mini",
"description": "Handles appointment booking over WhatsApp."
}'
# → { "id": "agt_...", "name": "Booking assistant", ... }Declare the tools it needs
One tool per endpoint the agent will call. See the tools declaration guide for the five blocks (description, input_schema, request, response, errors) and the three interpolation namespaces ({{input.*}}, {{binding.*}}, {{context.*}}).
curl -X POST https://cloud.gosendapi.com/v1/agent-tools \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "booking.search",
"description": "Search available slots.",
"declaration": { /* five blocks — see agents-tools guide */ }
}'
# → { "id": "tool_...", "slug": "booking.search", ... }Create the first AgentVersion
The version snapshots the system prompt + tool slugs + guardrails. Editing later = creating v2.
curl -X POST https://cloud.gosendapi.com/v1/agents/$AGENT_ID/versions \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "You are the booking assistant of ...",
"tools": ["booking.search", "booking.create"],
"guardrails_config": { "capExecucion": 10, "capConversacion": 20 }
}'
# → { "id": "ver_...", "version": 1, ... }Bind the version to a phone/customer/tenant
The binding is what actually turns the agent on for a given scope. Same version can be reused across many bindings (multi-tenant SaaS pattern).
curl -X POST https://cloud.gosendapi.com/v1/agent-bindings \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Fitz Roy Clinic — pilot",
"agent_version_id": "ver_...",
"scope": "phone_number",
"phone_number_id": "phn_...",
"params": { "wizardEnabled": false, "catchAllAction": "escalate" },
"reglas": { "tono": "Cordial, use vos.", "identidadIA": "Say you are the virtual assistant if asked." }
}'
# → { "id": "bnd_...", "has_secrets": false, ... }reglas is free-form JSON rendered into the system prompt at runtime — write instructions, not keywords. params is runtime config the agent engine reads (kill switches, wizard flags). See rules vs params below.
Attach encrypted secrets (admin-only)
Anything the tool needs to authenticate against your API — API keys, tenant-scoped tokens, JWT credentials. Never travel in cleartext except during the initial POST.
curl -X POST https://cloud.gosendapi.com/v1/admin/agent-bindings/$BINDING_ID/secrets \
-H "X-Admin-Key: $GOSENDAPI_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"secrets": {
"erp_api_key": "sk_live_...",
"erp_tenant_slug": "fitzroy"
}
}'Now every {{binding.secret.erp_api_key}} in your tool declarations resolves at runtime. Secrets are AES-256-GCM encrypted at rest and never returned by any GET endpoint.
That’s it — the next inbound message on that phone number goes through the agent loop.
Rules vs Params vs Secrets
Three separate JSON objects on AgentBinding, each with a different consumer:
| Field | Read by | Format | Example |
|---|---|---|---|
reglas | The LLM (rendered into system prompt) | Free-form JSON, keys are titles for prompt sections | {"tono": "Formal, use usted, no emojis."} |
params | The agent engine (runtime code) | Documented keys only | {"wizardEnabled": true, "catchAllAction": "escalate", "capConversacion": 30} |
secrets_encrypted | The tool executor (via {{binding.secret.*}}) | Key/value strings, admin-only | {"api_key": "sk_...", "webhook_secret": "..."} |
The most common mistake: putting catchAllAction in reglas and expecting the runtime to change behavior — it won’t. reglas only affects what the model says.
Documented params keys
Only these keys have runtime effect (anything else is silently ignored):
| Key | Type | Default | What it does |
|---|---|---|---|
wizardEnabled | boolean | false | Enables the deterministic booking wizard for the vertical when the router classifies the message as pedir_turno. |
wizardReserveToolSlug | string | none | Slug of the reservation tool the wizard should call (e.g. gs.crear_turno). Required if wizardEnabled: true. |
catchAllAction | "escalate" | "respond" | "escalate" | What the runtime does when the router lands on catch_all — escalate to a human or let the model respond freely. |
capConversacion | integer | 20 (platform-managed: 30) | Hard cap on the number of message pairs kept in the loop context before escalating. Prevents runaway threads. |
inactiveResetDays | integer | env AGENT_INACTIVE_RESET_DAYS (default 3) | Days of inactivity (since AgentThread.updated_at) after which the runtime clears messages=[] automatically. 0 disables the reset for this binding — the history grows forever. See Housekeeping — inactive thread reset. |
Versioning strategy
AgentVersion is immutable so you get safe rollouts without infrastructure:
- Pilot a new prompt — create v2, point one binding at it. If the pilot succeeds, PATCH the other bindings’
agent_version_id. - A/B testing — same version across bindings; different
reglasper binding for tone/copy variants. - Emergency rollback — one PATCH per binding back to the previous version. No code deploy.
- Clone & edit — duplicate a version (portal has a shortcut; via API, GET the version, POST a new one with tweaks).
You cannot mutate a version’s system_prompt, tools, guardrails_config, or routing. Attempting a PATCH will fail with 405. Delete only works if no bindings still reference it.
Guardrails floor
Every run passes through non-negotiable checks before, during and after the LLM loop:
- Urgency detector — Spanish medical patterns (respiratory, cardiac, neuro, bleeding, suicide, death-imminent, child+symptom, explicit
urgente/emergencia/SOS). Match → thread muted ashandoff. - Human intervention scan — if a human replied in the last 24h via
HUMAN_COEXISTENCE(mobile) orHUMAN_PORTAL→ thread muted ashuman_detected. - Conversation cap — default 20 messages (30 for platform-managed). Prevents runaway loops.
- Cost circuit breaker — per-thread ($1 default), per-tenant per-day ($50 default), per-tenant per-month ($500 default). Overridable per tenant.
- Output safety — if the LLM claims a factual action (“your appointment is booked”) without a successful matching tool call, the reply is blocked before send.
Every muting event is durable — see the next section for reactivation.
Reactivating a muted thread
Every mute has an explicit muted_reason so the runtime knows what kind of unmute (if any) is safe. These are the exact string values you get back in the muted_reason field of GET /v1/agent-threads:
muted_reason value | Auto-unmute | Rationale |
|---|---|---|
handoff | No — human only | Router escalated for judgement (clinical urgency, catch-all policy). Only a human decides when it’s safe |
human_detected | Yes — 24h since muted_at | Human took over the conversation; after silence, safe to hand back |
cost_cap_thread | No — human only | A single thread burned $1+ → signal of a bug in tools or a loop. Auto-unmute would burn the same $1 again |
cost_cap_tenant_day | Yes — 24h since muted_at | Rolling window matches the daily cap |
cost_cap_tenant_month | Yes — on the 1st of the next calendar month | Calendar window matches the monthly cap |
Manual reactivation via API works for any reason (overrides the “no auto-unmute” categories):
Related: resetting the message history
Reactivation clears the muted state. It does not clear AgentThread.messages. If you’re iterating on the system prompt and the accumulated history is making the model replay old patterns (see Milestone tools & auto-reset), reset the history separately:
curl -X POST https://cloud.gosendapi.com/v1/agent-threads/$THREAD_ID/reset-messages \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "iterating booking flow prompt to v23"}'This clears messages to [] and emits AgentEvent thread.messages_reset for audit. It does NOT unmute — use /reactivate for that.
# List every muted thread for your tenant
curl https://cloud.gosendapi.com/v1/agent-threads?muted=true \
-H "X-API-Key: $GOSENDAPI_KEY"
# Reactivate one
curl -X POST https://cloud.gosendapi.com/v1/agent-threads/$THREAD_ID/reactivate \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "False positive from urgency detector — patient just asked if it was a bot."}'The reactivation is atomic (mutedAt=NULL, mutedReason=NULL) and emits an AgentEvent thread.reactivated for audit — previous_muted_reason, reactivated_by_user_id, reason. Idempotent: reactivating an already-active thread is a no-op, no event emitted.
Detecting silent human intervention
Beyond the explicit handoff mechanism, the runtime scans the last 24h of each conversation for any message that came from a human channel and mutes the thread with human_detected if it finds one. The three channels the scan recognises:
Message.source | Meaning |
|---|---|
HUMAN_COEXISTENCE | Human replying from the WhatsApp Business App on the mobile phone. |
HUMAN_PORTAL | Human with a gsacloud account replying from the portal or from the inbox iframe with requireAgentLogin=true. |
HUMAN_EMBED_ANONYMOUS | Human replying from the inbox iframe without login (embed with requireAgentLogin=false or scope tenant/phone_number). There’s no sentByUserId because the operator has no gsacloud account, but the runtime knows it’s human because the message flowed through POST /v1/inbox-embed/conversations/:id/messages. |
If any of the three appear within the window, the agent stays silent. The reason is auto-reset later (see the inactivity reset matrix).
Housekeeping — inactive thread reset
AgentThread.messages grows every turn — the assistant reply plus each tool result gets appended. Without housekeeping the array grows forever, and every subsequent run pays the token cost of the whole history plus risks pattern replay from stale content (see milestone tools & auto-reset).
Three mechanisms clear it:
| Mechanism | Trigger | Configured by |
|---|---|---|
| Post-milestone auto-reset | A tool with terminates_thread: true returns ok=true | The tool declaration |
| Inactivity auto-reset | Thread’s updated_at is older than N days | binding.params.inactiveResetDays (falls back to env AGENT_INACTIVE_RESET_DAYS, default 3) |
| Manual reset | POST /v1/agent-threads/:id/reset-messages or the portal button | Ad-hoc — useful while iterating on the system prompt |
Tuning inactiveResetDays per binding
Different bindings on the same tenant can pick different windows depending on the vertical:
- Booking / appointments — 2 to 3 days. A patient that comes back a week later is almost always starting a new topic.
- Support / troubleshooting — 5 to 7 days. Longer troubleshooting arcs where multi-day context is useful.
- Notifications-only — set
0to disable (rare — you probably don’t want a growing history at all, in which case make sure your tools haveterminates_thread: true).
Set it in the binding’s params:
curl -X PATCH https://cloud.gosendapi.com/v1/agent-bindings/$BINDING_ID \
-H "X-API-Key: $GOSENDAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"params": {
"wizardEnabled": false,
"catchAllAction": "escalate",
"capConversacion": 20,
"inactiveResetDays": 2
}
}'Or fill it in the portal’s Params textarea when creating/editing the binding.
Every automatic reset emits an AgentEvent:
thread.messages_auto_reset— post-milestone (properties:milestone_tool,previous_message_count).thread.messages_inactivity_reset— inactivity (properties:previous_message_count,inactive_days,threshold_days,threshold_source).thread.messages_reset— manual (properties:previous_message_count,reset_by_user_id,reason).
Use these events for audit and to reason about token consumption on a tenant.
Ownership and errors
Every resource is scoped to the tenant behind the X-API-Key. Cross-tenant reads return 404 (not 403 — we don’t leak existence). Common conflicts:
| Situation | Status | Fix |
|---|---|---|
| Delete an Agent with active bindings | 409 | Delete or reassign the bindings first |
| Delete a Tool referenced by any AgentVersion | 409 | Roll a new version without that tool, then delete |
| PATCH an AgentVersion (any field) | 405 | Versions are immutable — create a new one |
Binding with scope=phone_number but the phone belongs to another customer | 409 | Fix the scope or the phone ownership |
Full error handling patterns: error handling guide.
Related
- AI Agents — Tools declaration — the five blocks + three namespaces + SSRF floor +
pickallowlist - AI Agents — LLM providers — provider registry, pricing snapshots, tool mapping per vendor, adding your own provider
- API Reference — interactive spec, every endpoint