Agent Invocation Contract
This page describes what you can send to an agent through POST /api/v2/agents/:id/stream (SSE) and the MCP tool chat_with_agent, and how to recognize a turn that ended because of time. It applies to anyone integrating their own system, the @zihin/agent-client SDK, or an automation platform.
For the SSE event format, see Streaming. For the other execution endpoints, see Execution.
1. Input
Body fields
| Field | Type | Who can send it | Purpose |
|---|---|---|---|
message | string | any credential | The user message (required unless there is an attachment). |
session_id | UUID | any credential | Continues an existing conversation. Omit on the first message. |
consumer_key | string | admin/owner API key | Identifies the person on the other side (per-person memory, denylist, engagement control). |
context | flat object | admin/owner API key | Data from the source system that the agent reads. |
bindings | flat object | admin/owner API key | Authoritative tool arguments. The model never sees them. |
session_from | list of names | admin/owner API key | Derives the session_id from context/bindings fields. |
metadata | flat object | any credential | Correlation (trace_id…). Never sent to the model. |
consumer_display_name | string | admin/owner API key (with consumer_key) | Name of the person, shown in the console contacts. See Contact name. |
capabilities | object | any credential | What your screen can render ({ "blocks": ["quick_replies"] }). See Suggested replies. |
options.timeout_ms | number | any credential | Shortens the turn deadline (see section 2). |
With a JWT or an editor/member API key, context, bindings, session_from and consumer_key do not raise an error: they are ignored and listed in ignored_fields (reason requires_admin_api_key). See How to know what was accepted.
consumer_key
Only valid with an admin or owner API key. Use one consumer_key per person (see below). To continue the conversations your integration already keeps (for example, the ones a webhook was recording), send exactly the same value it already uses. If the given session already belongs to a different consumer_key, the turn is refused with CONFLICT. A session that has no identity yet is adopted by the first declared consumer_key.
context: what the agent reads
- It travels together with the user message, as a block labeled as data from the source system. It is not part of the system prompt, so it does not invalidate the prompt cache.
- It is stored with the message (as
declared_context, with secrets redacted) and the same block is shown to the model wherever that message appears in the history (keys in a fixed order). - A flat object of scalars (string, number, boolean or
null;nullis dropped). Nested objects and lists are refused withVALIDATION_ERROR. - Limit of 2,000 characters in total (up to 50 fields, 2,000 characters per value). The limit exists because the block repeats on every message.
- A key with a secret-like name (
api_key,token,password…) or a value shaped like a credential is redacted ([REDACTED]) before it reaches the model and the record. - Reserved platform names are dropped and reported in
ignored_fields(reasonreserved_key, fieldcontext.<name>). Note thatbranch,processandtierare on the list and are common names in business systems. Rename them (office,workflow,level…). The full list:
tenant_id agent_id user_id source trigger_id execution_id api_key_id
session_id agent_execution_id root_execution_id trigger_execution_id
approval_gate auth_source consumer_key _webhookContext
_agent_depth parent_agent_id parent_session_id parent_execution_id
consumer_display_name consumer_channel _client_blocks
process _process_agent_step process_instance_id process_activity_id
builder_session_id builder_target_agent_id branch
databaseConfig tier
__proto__, constructor and prototype never get in either.
Contact name and one key per person
consumer_display_name (up to 255 characters) gives the contact a readable name in the console. Like consumer_key, it only takes effect with an admin/owner API key and a consumer_key; otherwise it is ignored.
- It applies from now on. It updates the contact and the conversation in progress. Conversations that already ended or expired keep the name they had at the time: history is not rewritten.
- A name declared by your integration replaces the current one. A name inferred by a webhook only fills a contact that has no name yet.
- Use one
consumer_keyper person. The key is the identity: per-person memory, denylist and engagement control belong to it. If several people share the same channel (for example, a sales WhatsApp number that changes seller), give each person their own key. Changing only the name does not separate people.
Suggested replies (capabilities)
capabilities declares what your screen can render. Today the only value with an effect is quick_replies:
{ "message": "I want blue pens", "capabilities": { "blocks": ["quick_replies"] } }
- No API key privilege is needed: any API key or JWT can send it. It applies to that request only.
- Without the declaration, the agent is not offered the suggestion tool, so nothing is generated and nothing is charged.
- The suggestions only come if the agent allows them (
quick_replies_enabled, on by default; change it withupdate_agentin the MCP or in the console) and the model decides when suggesting is worthwhile, so not every turn has them. - They arrive as a block in the
blocksfield of the SSEresponseevent:{ "type": "quick_replies", "data": { "labels": ["Pen A", "Pen B"] } }. Up to 4 labels of at most 60 characters, one group per turn. - The label is the text the user "says" when tapping it: send it as the next
message. - In a turn that has only suggestions,
contentcomes back empty. - Only on
/stream. The MCP toolchat_with_agentdoes not return blocks, and webhook and schedule turns are not offered the tool. - An unknown value in
blocksis reported inignored_fieldsascapabilities.blocks.<value>(not_supported); an unknown key insidecapabilitiesascapabilities.<key>(unknown_field); a wrong format (not an object,blocksnot a list of up to 10 strings) is aVALIDATION_ERROR. When accepted,capabilitiesappears inaccepted_fields.
When you reload the conversation (message pagination), the assistant message of a suggestions-only turn has content set to the labels as text ("1. Pen A\n2. Pen B"), for channels with no UI, and metadata.content_from_blocks: true. If you render blocks, ignore the text when you see that mark and draw the chips from blocks.
bindings: what the tools receive
- They fill
defaults_from_contextand${context.x}in the agent's tools (API and MCP). Use them for ids the model must not choose, such as the requester identifier. - They never reach the model and are not stored in the conversation.
- Same format and reserved-name rules as
context; limit of 16 KB in total. - If the same key exists in both
contextandbindings, the tools receive the value frombindings. - The context value always wins over whatever the model tries to send for the same parameter.
context, bindings or metadataKeep credentials in a tenant secret and reference it in the tool configuration. context and metadata are stored (with redaction by name/format, which is a safety net, not a guarantee). bindings is not stored, but it stays in the tools' context during the turn.
When a tool declares that a parameter comes from the context (defaults_from_context or ${context.x}) and the conversation did not provide that value, the platform is moving to refuse the call instead of letting the model fill it in. The tool returns the error CONTEXT_PARAM_MISSING to the model, and the API or MCP server is not called. The refusal is enabled by a platform operations switch (DEFAULTS_FROM_CONTEXT_STRICT); while it is not on in your environment, the event is only logged. The fix is always on the integration side: send the field in bindings (or context) on every turn, including consumer_key when the tool uses it.
session_from: session derived from fields
A list of 1 to 5 field names present in context or bindings. The session is derived from them with the same function as the webhook with session_strategy: derive: same agent and same values result in the same conversation, including one that was started by the webhook. You do not need to store the session_id.
- Do not combine it with
session_id: sending both is aVALIDATION_ERROR. - One field with a value is enough; no field with a value is a
VALIDATION_ERROR. - A field whose value was redacted (secret-like name in
context) is refused, because everyone would land in the same session. Send it inbindingsor use another field. - Phone fields (
phoneNumber,_waId,From) are normalized before the calculation, as in the webhook. The other fields are used as they are. The order of the fields is part of the calculation.
derive trigger = same conversationIf the agent has a trigger with session_strategy: derive and you use the same fields (with the same values) in session_from, the call lands in the same conversation as the trigger's channel. That is the desired behavior when migrating (see the guide in section 3), but it is surprising if you only wanted a separate conversation. To isolate it, use a different field value or send your own session_id.
metadata: correlation
- A flat object of scalars so you can find the execution by your system's id (
trace_id,correlation_id, order id). - It never reaches the model or the tools. It does not change the agent's behavior.
- Stored on the turn's execution, with secrets redacted.
- No special privilege is needed: any credential that can talk to the agent can send it.
- Limits: up to 20 keys, key name up to 64 characters (letters, digits,
_,.,-), text values up to 256 characters, and 4 KB in bytes in total. Anything beyond that is aVALIDATION_ERROR. - Lookup:
GET /api/v1/executions?trace_id=<value>.
options.timeout_ms
It only shortens the turn deadline; see section 2. Any other key in options (for example temperature and max_tokens) has no effect and appears in ignored_fields with reason not_supported. Temperature and output cap are agent configuration (llm_config), not part of the call.
How to know what was accepted
The first SSE event, metadata, reports what the platform did with each field:
{
"accepted_fields": ["context", "bindings", "session_from", "metadata", "consumer_key", "capabilities", "options.timeout_ms"],
"ignored_fields": [
{ "field": "context.branch", "reason": "reserved_key" },
{ "field": "options.temperature", "reason": "not_supported" }
]
}
accepted_fields: list of the field names that were applied.ignored_fields: list of{ field, reason }. Reasons:
reason | Meaning |
|---|---|
requires_admin_api_key | The credential is not an admin/owner API key (applies to context, bindings, session_from and consumer_key; a consumer_display_name sent without the privilege is dropped together with consumer_key and is not listed separately). |
reserved_key | Reserved platform name (context.branch, bindings.tier, metadata.__proto__…). |
unknown_field | A body field the /stream endpoint does not know (or an unknown key inside capabilities). |
not_supported | A known option that has no effect (options.temperature, options.max_tokens…, or a capabilities.blocks value other than quick_replies). |
If you depend on a field, check accepted_fields and abort when it is missing: the turn runs anyway, just without the field. The SDK does this check for you.
In the MCP tool chat_with_agent, the same accepted_fields and ignored_fields come back in the response (also in the TURN_TIMEOUT error). The tool accepts message, session_id, consumer_key, context, bindings, session_from and metadata, with the same rules as above. In MCP, consumer_key and the integration fields also require an admin/owner API key.
Example: curl
curl -N -X POST "https://llm.zihin.ai/api/v2/agents/$AGENT_ID/stream" \
-H "X-Api-Key: $ZIHIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message": "What is the status of my order?",
"consumer_key": "5511999990000",
"context": { "customer": "Ana", "plan": "pro" },
"bindings": { "requesterId": "u-4821", "supplierHash": "f-81c2" },
"session_from": ["supplierHash"],
"metadata": { "trace_id": "order-9931" },
"options": { "timeout_ms": 60000 }
}'
Then, to find the execution by your own id:
curl "https://llm.zihin.ai/api/v1/executions?trace_id=order-9931" \
-H "X-Api-Key: $ZIHIN_API_KEY"
Example: @zihin/agent-client SDK
consumerKey, context, bindings and sessionFrom arrive in SDK version 0.6 (0.5 does not send them). metadata and options.timeoutMs already exist before that.
consumer: { key, name }, capabilities and the suggestions result arrive in version 0.6.1 (still being published).
import { createClient } from '@zihin/agent-client';
const zihin = createClient({ apiKey: process.env.ZIHIN_API_KEY });
for await (const chunk of zihin.streamAgent({
agentId,
message: 'What is the status of my order?',
consumerKey: '5511999990000',
context: { customer: 'Ana', plan: 'pro' },
bindings: { requesterId: 'u-4821', supplierHash: 'f-81c2' },
sessionFrom: ['supplierHash'],
metadata: { traceId: 'order-9931' },
options: { timeoutMs: 60_000 },
})) {
if (chunk.type === 'token') process.stdout.write(chunk.content);
}
The SDK validates the fields before spending a turn (nested object, sessionFrom together with sessionId, a sessionFrom field with no value) and throws ZihinAgentError with code config if a requested field does not come back in accepted_fields or comes back in ignored_fields. invokeAgent accepts the same parameters and exposes acceptedFields and ignoredFields in the result. See @zihin/agent-client.
2. Output and timing
Turn deadline
A turn has one deadline. When it expires, the turn ends with the TIMEOUT outcome.
| Channel | Deadline |
|---|---|
Chat (/stream and native chat) | 150 s |
| Other channels (webhook, schedule, email, database event, MCP) | 180 s |
Sub-agent (invoke_agent) | child_timeout_ms from the agent's security policy (default 60 s) |
The deadline clock starts when the model loop starts, after the configuration and tools are loaded. A cold MCP server can take tens of seconds in that phase, so the total time the client sees is "load + deadline". If you keep a local clock, leave headroom.
timeout_ms only shortens
| Channel | Field |
|---|---|
POST /api/v2/agents/:id/stream | options.timeout_ms |
| Trigger webhook | trigger_config.execution.timeout_ms (wins) or trigger_config.timeout_ms |
The effective deadline is min(platform deadline, requested deadline). Asking for more than the platform gives does not extend the turn. An invalid value (non-numeric or below 1000 ms) is ignored, not refused. If the field is omitted, the platform deadline applies: there is no 30 s default.
There is no way to extend it. Tasks longer than 180 s do not fit in a synchronous turn: use the async webhook (execution.mode: "async" with a callback).
How to recognize a turn that ended by time
On /stream, the done event carries the outcome:
{ "outcome": "TIMEOUT", "timed_out": true, "timeout_clock": "turn" }
The timed_out and timeout_clock fields only appear when the turn ended by time. timeout_clock says which clock fired:
timeout_clock | What it measures |
|---|---|
turn | The total turn deadline (150 s in chat, 180 s elsewhere, or the requested timeout_ms if shorter). |
first_signal | How long the model provider took to start answering (streamed call). |
idle | The maximum silence between two pieces of the model's response. |
call | The duration of the model call when it is not streamed. |
On first_signal, the runtime makes one new attempt on the same model before failing.
Other surfaces use the same vocabulary:
- MCP
chat_with_agent: exceeding the turn deadline becomes aTURN_TIMEOUTerror withtimed_out: true,timeout_clock: "turn",execution_idandsession_id. Exceeding a model clock comes back as a response withsuccess: false,outcome: "TIMEOUT",timed_out: trueandtimeout_clock. - Connection: if the SSE transport expires before the turn, the
errorevent carriescode: "EXECUTION_TIMEOUT"(orSTREAM_TIMEOUT) andtimed_out: true. This is a connection failure, not a turn outcome. - Record: the execution is stored with
status = timeout. - Synchronous webhook: the response body keeps
successmeaning only whether the engine threw an error; aTIMEOUTturn can come back withsuccess: trueand the failure message in the text. For the real outcome, use/streamor look at the trigger execution.
What a timeout does not do
- A slow tool does not end the turn. A tool's limit is the API tool's
timeout_ms(editor_schema.api.timeout_ms) or the MCP server'scall_timeout_ms, from 1 s to 120 s (default 30 s). When it expires, the model is told the tool did not answer in time and decides what to do. - A timeout does not switch models. When a model clock fires, the turn fails without falling back to the next model in the chain. Provider errors (5xx, 429, network failure) do switch models, as long as no content of the response has been emitted yet.
3. From webhook to SDK with streaming
If you call the agent through a webhook today and want streamed responses, you can move to /stream (or to the SDK) without losing the conversations in progress.
| In the webhook | In /stream / SDK |
|---|---|
context_mapping (field the model should read) | context |
context_mapping (field only the tools use, e.g. defaults_from_context, names with a _ prefix) | bindings |
session_strategy.fields (mode: derive) | session_from (same names, same order) |
Person identity (phone, consumer_key) | consumer_key, with the same value the webhook was recording |
| Callback authentication and credentials | Tenant secret (never in context, bindings or metadata) |
| The trigger's agent | The same agent_id |
Step by step:
- Use an
adminorownerAPI key. With any other credential, the integration fields are ignored. - Map the fields. Each
context_mappingentry becomes acontextkey (the model reads it) or abindingskey (tools only). Rename any that collide with the reserved names. - Derive the session. Copy the names in
session_strategy.fieldsintosession_from, with the values incontextorbindings. Since the derivation is the same, the same agent with the same values lands in the same conversation as the webhook, and it continues where it left off. - Keep the identity. Send a
consumer_keyequal to the one the webhook used; a value in a different format on an existing session getsCONFLICT. - Check the
metadataevent. Every field you sent must be inaccepted_fields. - Credentials stay in a tenant secret, referenced in the tool configuration.
Example: a trigger with "session_strategy": { "mode": "derive", "fields": ["supplierHash"] } and context_mapping { "supplierHash": "$.supplier.hash" } is equivalent to:
{
"message": "What is the delivery time?",
"bindings": { "supplierHash": "f-81c2" },
"session_from": ["supplierHash"]
}
If the model also needs to read the value, send it in context instead of bindings.