This article is auto-synced from its in-app version in Tai.
The Agent REST API lets a server-side integration call one of your agents over HTTP — send typed input parameters, get back a schema-validated JSON answer — with no chat session to manage and no conversation state carried between calls. A call that finishes quickly answers inline; one that takes longer hands you back a run to poll, so a slow agent doesn't cost you the answer just because your connection didn't wait for it (see Long-running runs below).
When you'd use this
Use the REST API when your own backend, a scheduled job, or another system needs a single, structured answer from an agent — for example, running a risk check against a booking and getting a typed verdict back, rather than driving a conversational chat session.
Each agent that exposes this channel publishes one or more versioned contracts — a contract pins the exact input parameters and output shape for that version, so your integration can keep working even as the agent evolves.
Set up the channel
A Company Admin sets the agent up in the Workbench: switches on its REST API channel, chooses the Accepted authentication, publishes a contract version, and runs a test call on the agent's Test API tab. Setting up an agent's REST API walks through each step. The agent must be a Company agent: a call with an API key to a Personal agent gets 404 AGENT_NOT_FOUND, even though the Test API tab works for anyone who can edit it.
Your integration then needs the agent's ID, the contract version to pin, and an API key — or a short-lived token minted from an OAuth client's secret — described under Authenticate below.
Example: a travel-season recommender
A small agent that takes a destination and answers with the best times of year to travel there. It shows the whole loop: the contract you declare, the call an integration sends, and the answer it gets back.
1. Declare the input. In a draft contract version, add one input parameter (Setting up an agent's REST API shows where):
Field | Value |
Name |
|
Label |
|
Type | String |
Required | Checked |
Description |
|
2. Declare the output. Replace the default {"type": "object"} under Output JSON Schema with:
{
"type": "object",
"required": ["destination", "seasons"],
"additionalProperties": false,
"properties": {
"destination": {
"type": "string",
"description": "The destination as you understood it, e.g. \"Portugal\"."
},
"seasons": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["title", "start_date", "end_date", "explainer"],
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"description": "Short name, e.g. \"Spring\"."
},
"start_date": {
"type": "string",
"pattern": "^\\d{2}-\\d{2}$",
"description": "First day of the season as MM-DD. Seasons repeat every year."
},
"end_date": {
"type": "string",
"pattern": "^\\d{2}-\\d{2}$",
"description": "Last day of the season as MM-DD; earlier than start_date when the season runs over New Year."
},
"explainer": {
"type": "string",
"description": "One or two sentences on why to travel then."
}
}
}
}
}
}
3. Publish the version and call it:
curl https://taitravel.app/api/v1/agents/invoke \
-H "X-API-Key: $TAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent": "your-agent-id", "contract_version": 1, "params": {"destination": "PT"}}'
The envelope in the answer (see The response envelope below) then looks like:
{
"destination": "Portugal",
"seasons": [
{
"title": "Spring",
"start_date": "04-01",
"end_date": "06-15",
"explainer": "Mild temperatures and a green countryside, before the summer crowds and prices."
},
{
"title": "Early autumn",
"start_date": "09-10",
"end_date": "10-31",
"explainer": "The sea is still warm and the beaches are quieter than in July and August."
}
]
}
Do you need to explain the inputs and outputs in the system prompt? No. On every call, TAI hands the agent your inputs as labelled lines — here Destination (destination): PT — together with the version's output JSON Schema and an instruction to answer with JSON only. It then checks the answer against the schema and, if it doesn't match, asks the agent once to correct it. The contract alone is enough for a valid answer. Use the system prompt for what the contract can't say:
How to judge. Which factors count, how many results you want, what to do with an ambiguous input.
What an input means. A parameter's Description is shown to integrators and on the Test API tab, but the agent doesn't see it — it sees the label, the name and the value. If an input needs explaining, say so in the system prompt or in the label. A
descriptioninside the output JSON Schema, on the other hand, does reach the agent.No competing format. Don't ask for prose or Markdown: it works against the JSON instruction, can cost a corrective retry, and can fail the call with
AGENT_OUTPUT_ERROR.
A system prompt for this agent can be as short as:
You recommend the best times of year to travel to a destination. The destination may be an ISO country code (PT), a country name (Portugal) or a region (Algarve). Weigh weather, crowds and prices. Return two to four seasons in calendar order. If a region differs from the rest of its country, describe the region.
Authenticate
Every call carries an API key in an X-API-Key header — not Authorization: Bearer — or, instead, a short-lived token (see below):
curl https://taitravel.app/api/v1/agents/invoke \
-H "X-API-Key: $TAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent": "your-agent-id", "params": {}}'
The key must be scoped to the agent you're calling, allow the REST API channel, and carry the Invoke agents permission. A key that's valid but outside its scope gets the same response as a request naming an agent that doesn't exist — see Errors below.
By default, a new agent accepts an API key and an OAuth client (a short-lived token minted with an OAuth client's secret, described next). Some agents set up earlier accept an API key only; if a token call gets AUTH_METHOD_NOT_ACCEPTED, tick OAuth client on the agent. Anyone who can edit the agent can change this, under Configuration → Channels → Accepted authentication: untick API key to force integrations onto short-lived tokens, or tick either of these to also accept:
Workbench user — a signed-in Workbench user with permission to run agents, calling with their own session instead of a key.
TripBuilder FIT (Federated Identity Token) — a caller authenticated with a TripBuilder-issued federated identity token.
For a server-to-server integration, keep the default — an API key or an OAuth client is what this guide describes. Widening the setting matters only if you also want a Workbench user or a TripBuilder session to call the same endpoint directly. A call using a method the agent doesn't accept gets 403 AUTH_METHOD_NOT_ACCEPTED — see Errors below.
On a Personal agent, Accepted authentication is fixed to Workbench user: only the agent's owner or a Company Admin, signed in to the Workbench, can call it. API keys, OAuth clients, and TripBuilder sessions never reach a personal agent. When a Company Admin releases it with Release to company, the confirmation offers to also accept API keys and OAuth clients — ticked by default. Return to owner sets it back to Workbench user only.
Short-lived tokens (OAuth 2.0 client credentials)
Instead of sending a long-lived key on every call, your integration can use an OAuth client: it trades a client secret for an access token that expires after 15 minutes. The token carries the client's agent and channel scope, so a leaked token is useful for minutes, not until someone notices. The client secret stays on the machine that requests tokens, and it goes only to the token endpoint.
Create an OAuth client. A Company Admin clicks New API key or OAuth client under Settings → API Keys and chooses the OAuth client type. It is set up like an API key — agents, channels, permissions, IP restrictions, expiry — but has no key value. On creation the page shows its Client ID (the client_id) and its first client secret (the client_secret); copy the secret immediately, as it is shown once. An API key's value is never accepted as a client secret.
Rotate the client secret. An OAuth client can have up to two active client secrets, so you can rotate without downtime: open the client, click Generate client secret in the OAuth client secrets card, switch your integration to the new secret, then revoke the old one.
curl https://taitravel.app/api/v1/oauth/token \ -u "$TAI_CLIENT_ID:$TAI_CLIENT_SECRET" \ -d grant_type=client_credentials
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "agent:execute agent:read report:content_read report:read"
}
Send the token as Authorization: Bearer <access_token> — on the same endpoints, instead of X-API-Key:
curl https://taitravel.app/api/v1/agents/invoke \
-H "Authorization: Bearer $TAI_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent": "your-agent-id", "params": {}}'
You can also send client_id and client_secret as form fields rather than HTTP Basic credentials, but not both in one request.
Scope. Without a
scopeparameter the token carries every permission the client holds. Passscope=agent:execute(space-separated for several) to request fewer; asking for one the client lacks fails withinvalid_scope.Cache the token. Reuse it until shortly before
expires_inruns out, then fetch a new one. There is no refresh token: request a new one with the client secret. Standard OAuth client libraries (Spring Security, Authlib, requests-oauthlib, .NET and others) do this for you. Minting a token per call runs into the per-client token limit (see Rate limits below).Revoking the client or the secret revokes its tokens. Revoking or narrowing the client, revoking the client secret a token was issued with, or suspending your company stops its tokens within about a minute — you don't wait for them to expire.
The client's restrictions apply. An IP or referer restriction on the client is checked both when you request a token and on every call that uses it.
For how this compares with the other ways to connect, see Agent authentication.
Token errors follow the standard OAuth 2.0 shape rather than the TAI error envelope:
{ "error": "invalid_client", "error_description": "Client authentication failed" }
Status |
| Cause |
400 |
|
|
400 |
| Only |
400 |
| The requested |
401 |
| Unknown client ID (or the ID of an API key), wrong or revoked client secret, an API key's value used as the secret, a revoked or expired client, a client blocked by its IP or referer restriction, or a suspended company |
429 |
| Too many tokens for this client, or too many failed token requests from your address — this one uses the TAI error envelope and a |
Discover the agent's contract
Look up an agent's contract — the input parameters it accepts and the output shape it returns — when you build or deploy your integration, not on every call. A pinned integration already knows the shape it sends and receives; re-reading the contract per invoke doubles your request count and adds a round-trip to every answer.
curl "https://taitravel.app/api/v1/agents?ref=your-agent-id" \ -H "X-API-Key: $TAI_API_KEY"
ref takes the agent's UUID — it's the last segment of the agent's URL in the Workbench — or, for a built-in Nezasa agent, its nezasa/… slug, and returns at most one agent. The key needs the Read agents permission. The response's channel_configs entry for the REST API channel carries the full list of contract versions:
{
"data": [
{
"id": "a1b2c3d4-...",
"channel_configs": [
{
"channel_type": "REST_API",
"enabled": true,
"contracts": [
{
"version": 1,
"status": "ACTIVE",
"input_schema": [
{"name": "booking_ref", "type": "REFERENCE", "ref_kind": "booking_ref", "label": "Booking reference", "required": true}
],
"output_schema": {
"type": "object",
"properties": {
"risk_level": {"type": "string"},
"summary": {"type": "string"}
}
}
}
]
}
]
}
]
}
Each input_schema entry declares a type — one of TEXT, STRING, NUMBER, DATE, DATE_RANGE, REFERENCE or JSON — and whether it's required. The same channel_configs entry also carries timeout_seconds, the agent's wall-time budget for a call.
Pin the contract version you integrate against. If you never pin one, TAI picks the newest usable version for you on every call, which means your integration's inputs and outputs can change out from under it the moment a new version ships.
The contract list may include versions with status: "DRAFT" — a version still being prepared. A DRAFT can never be invoked, whether or not you pin it; wait for it to become ACTIVE before building against it.
Invoke the agent
POST /api/v1/agents/invoke, with a body of:
{
"agent": "your-agent-id",
"contract_version": 1,
"params": {
"booking_ref": "BK-12345"
}
}
agent— the agent's UUID, or a built-in'snezasa/…slug; the same valuereftakes above.contract_version— optional; pin it (see above).params— an object whose top-level keys are flat: every one must be declared in the resolved contract'sinput_schema, and an undeclared key, a missing required one, or a mistyped value is rejected before the agent runs. AJSON-typed param's value may nest, and is checked against the schema the contract declares for it. Serialized,paramsis capped at 256 KB.
The response envelope
A successful call returns:
{
"data": {
"agent_id": "your-agent-id",
"envelope": {
"risk_level": "medium",
"summary": "One flight connection under 45 minutes."
},
"invocation_meta": {
"duration_ms": 2140,
"contract_version": 1,
"deprecation": null,
"consumption": null,
"internal": null
}
},
"meta": {
"request_id": "..."
}
}
agent_id is the agent's canonical id — its UUID, or a built-in's nezasa/… slug. That's the agent's one published identifier, so it may differ from what you sent as agent: address a built-in by its UUID and you get its slug back. envelope is your agent's answer, already validated against the contract's output_schema. If the agent's first answer fails that validation it gets one corrective turn. It's still one request, and what it costs you covers both turns.
invocation_meta.duration_ms is how long the run took, and contract_version the version that answered. invocation_meta.consumption is the run's credit figure; it is filled in for Workbench users (for example in the agent's Test API tab) and is always null on API-key and OAuth-client calls, so do not rely on it in an integration. invocation_meta.internal is reserved and always null for you. Your company admins see the credits your calls consumed in the Billing Center.
If the resolved contract is DEPRECATED, invocation_meta.deprecation is populated instead of null, and the response also carries two headers, both HTTP-date formatted: Deprecation, the date the version was deprecated, and Sunset, the date it stops working. deprecation.latest_active_version names the version to move to — enough for your integration to log, alert, or migrate on its own before the sunset date. Your company's admins also get an email within about a week of one of your API keys calling a deprecated version, and again as its sunset date approaches.
Long-running runs
Every call to /agents/invoke is a durable, resumable run under the hood — a 200 response is just the case where it finished before you stopped waiting. A run that takes longer keeps going without you: you get back 202 Accepted with a Location header pointing at the run, and a Retry-After: 5. The run then has up to its own budget — 10 minutes, or less if your agent's REST API channel sets a shorter one — to finish, and its result waits for you however many polls that takes. Past that budget it ends as a FAILED run with an AGENT_TIMEOUT. Treat a 202 as a normal, expected outcome — not an error — for any agent whose answer can take more than a few seconds.
{
"data": {
"id": "5b6a...",
"status": "QUEUED",
"agent_id": "your-agent-id",
"contract_version": 1,
"created_at": "...",
"started_at": null,
"finished_at": null,
"expires_at": null,
"result": null,
"error": null
},
"meta": { "request_id": "..." }
}
Poll the run with GET /api/v1/agents/runs/{id}. It long-polls by default — it holds the connection for up to 25 seconds waiting for the run to finish, so your loop doesn't spin. ?wait=N sets your own window (seconds, capped at 55); ?wait=0 returns the run's current state immediately. Unlike the invoke call itself, GET never raises an error for a run that finished badly — a FAILED run comes back as an ordinary 200 with status: "FAILED" and an error: {code, message} object in place of result, and a CANCELLED one likewise, so your poll loop only ever needs to branch on data.status.
Cancel a run you no longer need with POST /api/v1/agents/runs/{id}/cancel. A run already in progress isn't interrupted mid-turn — it finishes its current turn (and is billed for it) before the cancellation takes effect — but no further work happens after that. Cancelling a run that has already finished returns 409.
agent_id on a run is the agent's canonical id, exactly as on the inline answer above, and expires_at is when the finished run is deleted — after that its Idempotency-Key starts a fresh run rather than re-attaching.
Skip the wait entirely by sending Prefer: respond-async on the original POST /agents/invoke — you get a 202 immediately, every time, regardless of how fast the agent would have answered. Use this when your integration always polls anyway and would rather not hold the connection open at all.
Retry safely with an Idempotency-Key header on POST /agents/invoke. The same key (scoped to your API key and the agent you're calling) re-attaches to the run your first attempt already created instead of starting a second one — so a dropped connection or a client-side retry never double-runs, and never double-bills, the same call. That re-attach happens even while you're over your per-key, per-agent or per-company rate limit, or over your credit budget — it isn't new work, so it isn't refused as if it were. The invoke-admission limit still applies, because it counts attempts rather than work, so keep backing off on a 429 that names that scope. Reusing a key with a different body is treated as a mistake, not a retry, and returns 422 IDEMPOTENCY_KEY_MISMATCH. A key is honoured only until its run's TTL expires (currently 24 hours after the run finished); after that the same key starts a fresh run. Max 255 characters.
Attach with SSE (optional)
If your infrastructure tolerates Server-Sent Events, GET /api/v1/agents/runs/{id}/stream attaches to a run and pushes its outcome instead of making you poll for it. The stream sends :keepalive comment lines while the run is still going, then exactly one terminal result event carrying the same run object GET /agents/runs/{id} returns — a FAILED or CANCELLED run arrives the same way, so you branch on data.status here exactly as you would when polling — and finally a done event that closes the stream. It never sends the agent's answer incrementally; you still get the full result in one frame, just pushed instead of polled.
Polling stays the primary, recommended way to check on a run — it works everywhere. Reach for SSE only if your client and everything between it and TAI (proxies, gateways, load balancers) passes event-stream responses through untouched; if you're not sure, poll.
event: result
data: {"data": {"id": "5b6a...", "status": "SUCCEEDED", ...}, "meta": {"request_id": "..."}}event: done
data: {}
Handle the error event too. It means the stream could not deliver a run — never that the run failed, which is a result event with status: "FAILED". You get one if the run was deleted while you were attached, if it outlived the stream's 15-minute cap (STREAM_WAIT_EXPIRED), or on an unexpected fault (INTERNAL_ERROR). The run is unaffected either way: fall back to GET /api/v1/agents/runs/{id} for its outcome. Note the event carries code and error directly — it is a stream frame, not the HTTP error body described under Errors below.
event: error
data: {"code": "STREAM_WAIT_EXPIRED", "error": "The run did not finish inside the stream's wait budget; ..."}event: done
data: {}
Close the connection when done arrives. The stream is one attach, not a subscription — a client that reconnects automatically (a browser EventSource does, by default) will re-attach in a loop and spend its rate-limit allowance on it.
Errors
Every error shares one JSON shape:
{
"error": {
"code": "AGENT_NOT_FOUND",
"message": "Agent `your-agent-id` not found",
"details": null
},
"meta": {
"request_id": "..."
}
}
details is null for most errors and carries structured context for a few: RATE_LIMIT_EXCEEDED names which limit fired (see Rate limits below), and CREDIT_BUDGET_EXCEEDED carries [{"reason": "budget_exceeded", "period": "MONTHLY", "reset_at": "..."}] — the instant your budget frees up.
The table below lists what an integration mistake produces. Any other failure uses this same envelope, so branch on error.code rather than assuming this list is closed — and when you meet a code you don't recognise, let the HTTP status decide: retry a 5xx, alert on a 4xx.
Status | Code | Cause |
401 |
| The API key or access token is missing, invalid, expired, or blocked by an IP/referer restriction on the key; or the key or client secret behind a token was revoked |
403 |
| The key, or the scope of the access token, lacks the Invoke agents permission |
403 |
| The calling identity's method (API key, OAuth client, Workbench user, or TripBuilder FIT) isn't accepted by the agent's Accepted authentication setting |
404 |
| The agent doesn't exist or isn't active, it is a Personal agent, its REST API channel isn't enabled, or the key isn't scoped to that agent or to the REST API channel — these are deliberately indistinguishable, so a 404 you didn't expect is worth checking against the key's scope first |
409 |
| You tried to cancel a run that has already finished, or (on the synchronous path) the run was cancelled by another caller while you were waiting on it |
409 |
| The agent's REST API channel was switched off after your run was queued. A run that hadn't started never runs and isn't billed; one already in progress stops before its retry and is billed only for the turn that ran. A synchronous call answers 409; polling returns |
410 |
| The pinned |
422 |
| Unknown |
422 |
| You reused an |
429 |
| A rate limit was exceeded — see Rate limits below |
429 |
| Your company's credit budget for the current period is used up. A 429, but not a rate limit — its |
502 |
| The agent couldn't produce a usable answer — the model call failed, or its answer failed output validation twice in a row — and this finished inside your wait window. Retryable, but the model already ran and was already billed, so cap your attempts and back off between them rather than retrying in a loop |
503 |
| Your run waited so long to start that it was abandoned before anything picked it up. Runs from companies with fewer runs in progress start first, so this usually means you queued more runs at once than could start in time. Nothing ran and nothing was billed, so this is safe to retry outright; back off first and keep fewer runs in flight at once |
503 |
| Checking a schema didn't finish in time. A transient platform condition, not a problem with your request: retry later rather than immediately — a tight retry loop only deepens the queue. If it fired while checking your |
504 |
| The agent didn't finish within its run's own wall-time budget and this finished inside your wait window — see Long-running runs above; usually you'll see a 202 (or a 200 with |
A malformed request body is the one exception. If the body itself doesn't parse as valid JSON matching the request shape (for example, agent is missing entirely), the response is still HTTP 422 but comes back in a different shape — {"detail": [...]} — not the {"error": {...}} envelope above. Handle both shapes at 422, or validate your request body against the contract before sending it so you only ever see the TAI shape.
Rate limits
A request must clear every limit that applies to it; the tightest one wins. Each returns RATE_LIMIT_EXCEEDED with a Retry-After header — sleep for that many seconds before retrying. Retrying sooner just spends the next window's allowance on rejected requests.
Scope | Default | Notes |
Per API key | Your company's setting, default 20 requests/minute | Not per endpoint — shared with everything else this key does, including chat sessions and report runs. Burst allowance ×1.5 |
Per company | Your company's setting, default 60 requests/minute | Shared across every key and user in your company; burst ×1.5 |
Per agent | Unset (the per-key limit above applies) | Part of the agent's own REST API configuration, not something a caller sets per request; burst ×1.5. It replaces the per-key limit for that agent, so it can raise a caller above it as well as lower it — still bounded by your company's overall limit. It is also shared: every caller of that agent draws on the same allowance. Ask a company admin to set or change one |
API key verification | 60 requests/minute | Keyed by your caller's IP address rather than by the key, and shared by every key calling from it. Guards against key guessing; a steady integration rarely meets it. Failed token requests to |
Tokens, per OAuth client | 60 requests/minute | Successful requests to |
Invoke attempts, per API key | Twice your company's overall limit | Counts attempts on |
A company admin sees and manages the per-key and per-company numbers above, and any per-agent overrides, from Settings → Rate limits. Nezasa sets your company's overall allocation; contact your Nezasa representative to raise it.
Each one names itself in the response body: error.details carries [{"scope": "principal"}] for the per-key limit, "company", "agent", "api-key-auth" for verification, "oauth-token" for token requests, or "invoke-admission" for invoke attempts — so your backoff can tell "this key is going too fast" apart from "your whole company is", and "you are calling an agent that doesn't exist" apart from either.
A client wrapper
Read the contract once (see Discover the agent's contract above), pin the version it gave you, then call the agent as often as you need:
async function invokeAgent(agent, params, contractVersion) {
const response = await fetch("https://taitravel.app/api/v1/agents/invoke", {
method: "POST",
headers: {
"X-API-Key": process.env.TAI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent, contract_version: contractVersion, params }),
}); if (!response.ok) {
// Parse defensively: an error from the load balancer in front of TAI is
// not JSON, and a malformed body's 422 carries `detail`, not `error`.
const body = await response.json().catch(() => null);
const code = body?.error?.code ?? (body?.detail ? "malformed request" : "no JSON body");
throw new Error(`TAI invoke failed (${response.status}: ${code})`);
}
// Parse defensively here too: a 2xx is not a promise of a JSON envelope.
const body = await response.json().catch(() => null);
if (!body?.data?.envelope) {
throw new Error(`TAI invoke returned no envelope (status ${response.status})`);
}
return body.data.envelope;
}
Branch your retry logic on error.code, not on the HTTP status alone — the two 429s want opposite handling, and only some 5xx codes are worth a second attempt.
Related
Interactive API reference — the live OpenAPI spec: every endpoint on this page with its exact parameters, headers and response schemas. Click Authorize, enter your API key (or your OAuth client's ID and secret), and use Try it out to send real calls. If your key or client has an IP or referer restriction, calls from this page must meet it too.
Setting up an agent's REST API — switching on the channel and publishing contracts in the Workbench
Agent authentication — which way in fits which caller, and what each protects against
Credit budgets — what a
CREDIT_BUDGET_EXCEEDEDresponse means and who can lift itTAI Chat SDK — for an embedded, conversational agent instead of a single structured call

