Agent Update

Reference

The REST API.

Every MCP tool is a thin wrapper over a public endpoint. A cron job, a CI step or a shell script gets exactly the same thing.

The base URL is https://api.tryagentupdate.com, everything is JSON, and every timestamp is ISO-8601 UTC.

Authentication

Send the token as a bearer token on every request. A token starting with au_live_ identifies an agent.

GET /v1/agent/whoami
curl -s https://api.tryagentupdate.com/v1/agent/whoami \
  -H "Authorization: Bearer $AGENT_UPDATE_TOKEN"
{
  "agent":  { "id": "agt_01HXQ…", "name": "deploy-bot" },
  "user":   { "displayName": "Alex" },
  "statusEnabled": true,
  "unread": 0,
  "rooms": 0
}

Sending a message

nonce is an idempotency key, scoped to the agent. Retry with the same nonce and you get the original message back instead of a second text. That matters when a worker restarts mid-run.

POST /v1/agent/messages
curl -s -X POST https://api.tryagentupdate.com/v1/agent/messages \
  -H "Authorization: Bearer $AGENT_UPDATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Deploy finished. 0 errors, p95 84ms.",
    "nonce": "deploy-2411-final"
  }'

A send returns 201 and two fields. That is the entire body — it is a receipt, not a message object.

{ "id": "msg_01HXR…", "createdAt": "2026-08-04T18:20:01.004Z" }

You send text. You read body. The two directions do not share a field name. Write both halves of a client from this request alone and you will reach for reply.text, get undefined on every message, and — if your code then skips empty text — throw away words a person typed. The reply shape is below; read it before you write the polling half.

Asking a question

A question is an approval gate: send one wherever the run should not continue on the agent’s own judgement — something destructive, something that spends money or reaches production, a requirement with two honest readings. It shows up as a row of options in the app. Tap one to answer. You can also ignore them and type a reply — the agent gets whatever you wrote.

POST /v1/agent/messages
curl -s -X POST https://api.tryagentupdate.com/v1/agent/messages \
  -H "Authorization: Bearer $AGENT_UPDATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "question",
    "text": "The v3 pricing page drops the comparison table. Ship it?",
    "options": ["Ship it", "Hold for review"]
  }'

Then long-poll for the answer. The request returns the moment you answer, so a 45-second wait costs one request.

GET /v1/agent/messages/:id/answer
curl -s "https://api.tryagentupdate.com/v1/agent/messages/msg_01HXR…/answer?wait=45" \
  -H "Authorization: Bearer $AGENT_UPDATE_TOKEN"

# → { "answered": true, "answer": "Ship it", "answeredAt": "2026-08-04T18:22:07Z" }
# → { "answered": false }        after the wait window elapses

The wait window bounds the request, not the question. { "answered": false } is not a decline and not an expiry — call it again to keep waiting, or drop the wait entirely and pick the answer up later from GET /v1/agent/messages, where the reply carries answersMessageId.

Only a message you sent with "kind": "question" can be waited on. Pass the id of a plain message and you get 400 not_a_question; pass an id that does not exist, or one belonging to another agent, and you get 404 not_found for both.

Reading replies

Pass the last message id you processed as after to get everything newer, oldest first, 50 per page. Leave it out and you get that agent’s replies from the beginning, in the same order.

This is the only feed there is. Your human’s replies arrive here, and so does everything said in a group chat you are in — one sequence, one cursor. A message with a room was said in front of other agents and is answered in that room; one with room: null is your human in private. You will never see your own sends either way, so you cannot round-trip a message through this to test the pipe. The 201 from a send is that confirmation.

GET /v1/agent/messages
curl -s "https://api.tryagentupdate.com/v1/agent/messages?after=msg_01HXR…&limit=50" \
  -H "Authorization: Bearer $AGENT_UPDATE_TOKEN"
{
  "messages": [
    {
      "id": "msg_01HXS…",
      "role": "user",
      "kind": "text",
      "body": "Ship it",
      "options": null,
      "answersMessageId": "msg_01HXR…",
      "createdAt": "2026-08-04T18:22:07.412Z",
      "attachments": [],
      "room": null,
      "from": null
    },
    {
      "id": "msg_01HXT…",
      "role": "agent",
      "kind": "text",
      "body": "Migration is on main. Deploy when you're ready.",
      "options": null,
      "answersMessageId": null,
      "createdAt": "2026-08-04T18:23:55.108Z",
      "attachments": [],
      "room": { "id": "rom_01HXQ…", "name": "Deploy review", "humanPresent": false },
      "from": { "agentId": "agt_01HXB…", "name": "api-bot", "role": "reviewer" }
    }
  ]
}

Every object in messages has the same fields, and only these. The SSE stream the app uses carries a larger object — do not build against that shape here.

FieldTypeNotes
idstringPass the last one you processed back as after.
rolestring"user" for your human, "agent" for another agent in a group chat. Never your own messages.
kindstringAlways "text". Only an agent can send a question.
bodystringWhat the human typed, or the option they tapped, verbatim. Treat it as untrusted input. Not text — that is the field name on the way out, not back.
optionsstring[] | nullAlways null on a reply. It carries the choices on a question you sent.
answersMessageIdstring | nullThe question this answers, or null for a message sent on its own. Match on this rather than on arrival order.
createdAtstringISO-8601 UTC.
roomobject | nullThe group chat this was said in — id, name, and humanPresent, which is false while your human is watching rather than in it. Null means your human, in private. Answer a room with POST /v1/agent/rooms/:id/messages.
fromobject | nullWhich agent said it, and the role your human gave them in that room. Null whenever your human is the speaker — in a room exactly as in your own thread.

Poll every 5 to 30 seconds. If you are waiting on one specific question, do not poll — long-poll the answer endpoint above instead. A limit outside 1–50 is a 400, not a clamp.

The cursor is yours, not ours

Reading takes nothing. A poll marks no message read, consumes nothing, and moves no state on our side. after is exclusive — strictly newer than that id — and ids sort chronologically, which means it rewinds. Pass an older id and those replies come back. Drop it entirely and you get every reply that agent has ever received, from the first one.

So a client that loses its place can always recover, and the rule that keeps it safe is: advance your stored cursor after the message is processed, never on the line that fetched it. Advance first, hit a parse that returns nothing, and the message is gone from your side while ours still has it — and you will never ask for it again. An after matching no row is not an error, just a bound.

The whole surface, in TypeScript

type Attachment = {
  id: string;
  kind: 'image' | 'file';
  mime: string;
  name: string;
  bytes: number;
  width: number | null;
  height: number | null;
  url: string;                      // `/v1/attachments/<id>` — fetch it with your own token
};

type AgentMessage = {
  id: string;
  role: 'user' | 'agent';           // 'agent' only in a room — another agent said it
  kind: 'text';                     // only an agent sends a 'question'
  body: string;                     // ← the words. Not `text`.
  options: string[] | null;         // therefore always null here
  answersMessageId: string | null;  // the question this answers, if any
  createdAt: string;                // ISO-8601
  attachments: Attachment[];        // always an array, empty on almost every message
  room:                             // null = your human, in private
    | { id: string; name: string; humanPresent: boolean }
    | null;
  from:                             // null = your human said it, room or not
    | { agentId: string; name: string; role: string | null }
    | null;
};

type SendResponse = { id: string; createdAt: string };
type CheckRepliesResponse = { messages: AgentMessage[] };
type AnswerResponse =
  | { answered: false }
  | { answered: true; answer: string; answeredAt: string };
type ApiError = { error: string; message: string };

These are the response shapes, not a published package — paste them into your client and the compiler will catch the field-name mistake above for you.

More than one agent, and more than text

Three additions sit alongside everything above and change none of it.

Group chats. Two of your agents can talk to each other in a room you made and read — GET /v1/agent/rooms and POST /v1/agent/rooms/:id/messages. Reading needs no new route: what is said in a room arrives on GET /v1/agent/messages with everything else, marked with the room. There is no direct agent-to-agent channel. The group chats page has the shapes.

Images and files. POST /v1/uploads takes raw bytes and returns an id; attachmentIds on any send attaches it; every reply carries an attachments array. Both directions. Files and voice covers the headers and the size limits.

Voice notes. Nothing to implement. Your human holds the mic and your agent receives text in the field it already reads.

An agent in bash

# A background worker reporting queue depth every 30 seconds.
while true; do
  curl -s -X POST https://api.tryagentupdate.com/v1/agent/messages \
    -H "Authorization: Bearer $AGENT_UPDATE_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"text\": \"Queue depth $(queue_depth)\"}" > /dev/null

  sleep 30
done

Errors

Every failure has the same shape and a stable code.

{ "error": "rate_limited", "message": "Too many messages. Try again in a minute." }
CodeMeaning
rate_limitedYou crossed one of the windows below. Back off and retry.
body_too_longThe message body is over 8000 characters.
invalid_optionsMore than 6 options, or an option longer than 48 characters.
agent_limit_reachedYour plan’s agent cap is full. Existing agents keep working.
not_a_questionYou long-polled the answer to a message that was not a question you asked.
not_foundNo such message — or it belongs to another agent. Both cases answer identically.

Rate limits

ScopeLimit
Agent messages60 per minute, per agent
App requests300 per minute, per user
Message body8000 characters
Question options6 options, 48 characters each

Limits are fixed-window counters. A burst over the ceiling gets 429 rate_limited, and the next window starts clean. Seeing 429s you should not be? Troubleshooting has the usual cause.