Agents that serve people
When a person asks an agent to do something, the agent runs as itself: its own Staff actor, its own paid seat, its own roles. The person who asked is the requester. The platform works out who the requester is from their signed chat message, records them on the run, and checks on every start (the run itself and every step it starts) that the requester may ask for it. The agent never holds the person's sign-in, token or seat.
What the agent can do for someone is the overlap of two things: what the agent itself may do, and what the requester may do. Nothing is open by default. An app admin opts each workflow in for agents, the requester's group must be granted it, and every write waits for a tap from the requester.
The seat rule
Only a live, paid Staff agent can work for a requester. Every request checks that the calling actor:
- is active and has exactly one live identity with an active
agt_token; - is in RBAC-scoped mode, which takes a paid actor seat (see Identity and tokens).
The agent's seat pays for the work, and runs, cost and audit are attributed to the agent. The requester's own seat is not touched, and the requester check takes no extra end-user seat. (In the platform chat, path A below, the actor still uses its usual chat attachment, which holds an end-user seat in the app. Path B needs none.) An organization API token, an integration key, or a chat key with no actor behind it cannot name a requester (REQUESTER_REQUIRES_AGENT).
Two ways a request arrives
| Path A: platform chat | Path B: your own harness | |
|---|---|---|
| Who listens to the chat | The platform chat bridge, for an actor attached to the space | Your agent process, connected to the chat with its own key |
| Who works out the requester | The platform, from the message that woke the actor | The platform, from the signed message your harness forwards |
| What your code sends | Nothing. The session is bound to the requester at launch | _orkestia_requested_by on each start (see the harness contract) |
| Credential | The agent's hosted session | The agent's agt_ token |
| How long one request lasts | 15 minutes after the message, for reads and approved writes. Longer with a task lease | 5 minutes after the message and at most 20 read starts. A write keeps its request usable while its approval lives. Longer with a task lease |
| Approval card | Posted by the agent as its answer | Posted by your harness |
Both paths use the same rules. A request message is always a chat message (kind 9) in one channel, mentioning the agent, written by a person who is an active member of the space.
What is checked on every start
For each start, top level and every step below it, the platform refuses unless all of these hold:
- The message is genuine. Its id and signature verify, it is a chat message in one channel of an active space of the agent's organization, and it mentions the calling agent's own key.
- It is fresh. Not older than the path's window, and not more than 60 seconds in the future.
- A person wrote it. The author is an active member of the space and a human end user of the space's app: active, seated, with a live account and app grant, and not signed out by an admin after the message was sent. Actors, the organization's agent keys, relay bots and unknown keys are refused.
- This agent claimed it. The first agent that uses a message claims it. Another agent presenting the same message is refused.
- The workflow is an exposed composition of the requester's app. Raw catalog workflows run only as steps inside an allowed composition.
- An app admin enabled it for agents (
agent_on_behalfon that exposure). - The requester's group is explicitly granted it. A capability no group manages is refused for agents, even though a person clicking it in your app may still run it.
- The requester is in the workspace the request belongs to, when there is one.
- Read or approved write. A read-only exposure rides on the message; anything else needs an approval tap (next section).
The check reads the database each time and caches nothing. If any part of it cannot be read, the start is refused.
Reads ride on the message, writes need a tap
A workflow is a write unless its exposure is marked read_only. An app admin sets the mark, and the platform accepts it only when every workflow the composition can start, nested compositions included, declares that it has no side effects. A wrong or missing mark costs the person a tap; it never lets an unapproved write through.
- Reads. A read-only exposure runs on the request message alone, inside the window: path A for 15 minutes after the message, path B for 5 minutes and at most 20 read starts.
- Writes. Every other start needs a per-action approval: the requester taps approve on a card that names the workflow and shows its input. The approval is bound to that exact composition version and that exact input. It is single use, expires 10 minutes after it was asked for, and covers that one run and everything it starts.
- Inheritance. A step started by a read-only run must itself be read-only. A step started by an approved write is covered by that approval, but it is still checked against the requester (exposure, agent opt-in, grant).
- Only the requester approves. A tap from anyone else is refused. A typed "ok", "yes" or "pode" approves nothing.
read_only. Expect a tap for most actions, reads included, until more workflows carry the declaration. When the mark is refused, identity.exposure.set-agent-policy lists the steps that block it.Set it up
A person in your organization does every step marked human only. The platform refuses those workflows when an agent starts them, so an agent can never widen its own reach.
Hire the agent with a seat
Hire the actor, switch it to RBAC-scoped mode (a paid actor seat) and mint its agt_ token under Admin → RBAC seats. See Identity and tokens.
Expose the compositions
Expose each composition the agent may run for people on your app, with identity.app.expose-virtual-workflow. This includes compositions nested as steps of another one: each is checked against the requester when it starts.
Enable each exposure for agents (human only)
start_workflow("identity.exposure.set-agent-policy", {
"identity_app_uuid": "<app>",
"composition_uuid": "<any version of the composition>",
"agent_on_behalf": true
})
The setting applies to every exposed version of that composition on the app, and new versions keep it. Add "read_only": true for a composition that only reads. It is refused, and nothing changes, unless every step declares no side effects; the output then lists them in read_only_blockers. A new version that adds a step with side effects loses read_only on expose. "agent_on_behalf": false closes the exposure to agents again.
Grant the requesters' groups
The requester's group must hold an explicit grant for the composition: vw:<lineage_uuid>:invoke (or vw:<lineage_uuid>:*, or vw:*). The lineage_uuid comes from composition.save. The owner's * does not count.
start_workflow("identity.end-user.group.set-capabilities", {
"identity_app_uuid": "<app>",
"group_slug": "sales",
"capabilities": ["vw:<lineage_uuid>:invoke"]
})
set-capabilities replaces the group's whole list, so send the existing capabilities too. Put people in the group with identity.end-user.assign-group, or give them that role in a workspace.
Agent-only grants, for admin groups (rolling out, see below). A vw: grant also decides what people in your app may click, and granting vw:* to one group takes the open default away from every other group. To let a group (your admins, for example) ask agents for every agent-enabled composition without changing what anyone can click, grant an agent-only capability instead:
| Capability | Lets the group's people ask an agent for |
|---|---|
agent:vw:<lineage_uuid>:invoke (or agent:vw:<lineage_uuid>:*) | That one composition |
agent:vw:* | Every composition of the app that is enabled for agents |
Agent-only grants are read only by the requester check. They never change who may run a composition directly, and the exposure must still be enabled for agents. set-capabilities is human only and refuses any other agent: form, or one naming a composition your app does not expose.
Require a requester (human only)
start_workflow("staff.set-actor-requester-required", {
"organization_uuid": "<your organization>",
"actor_uuid": "<actor uuid>",
"requester_required": true
})
From then on every start this actor makes must name a requester, and is refused with REQUESTER_REQUIRED otherwise. It needs the admin role on the actor. The flag stays on the actor when its token is rotated.
In chat (path A) this flag is also what turns the requester check on: an attached actor without it answers exactly as before, as described on Actors in chat.
Connect the chat
- Path A. Attach the actor to the space as on Actors in chat: bind its end-user seat,
buzz.actor.attach,buzz.bridge.sync. - Path B. Your harness signs with its own key, which the platform never holds. An organization admin or owner registers that key, adds it to the space, and binds it to the actor (human only):
start_workflow("buzz.org.set-agent-keys", {
"add": [{"public_key": "<64 hex>", "display_name": "Sales agent"}]
})
start_workflow("buzz.member.add-pubkey", {
"space_uuid": "<space uuid>",
"pubkey": "<64 hex>"
})
start_workflow("buzz.org-agent-key.bind-actor", {
"public_key": "<64 hex>",
"staff_actor_uuid": "<actor uuid>"
})
One active key per actor. An empty staff_actor_uuid unbinds. Without the binding, no request to that agent resolves (REQUESTER_AGENT_KEY_UNBOUND).
Path A: in the platform chat
When a person mentions a requester-required actor, the platform resolves them from that message and binds the actor's session to them. Inside that session:
- the agent may call only compositions your app exposes (each checked as above) and the person's own paired Orkestia Browser. Other skills, MCP servers and generic workflow tools are refused with
REQUESTER_RAW_TYPE_REFUSED; - only the requester may send messages into the session (
REQUESTER_SESSION_MISMATCHfor anyone else); - the session can act for 15 minutes after the message. After that, the person sends the request again;
- a call your organization's tool policy sends to an org reviewer is refused (
REQUESTER_ORG_APPROVAL_UNSUPPORTED): the person's own tap is the only approval here.
For a write, the agent asks for an approval card and posts it as its answer, with approve and deny buttons. When the person taps approve, the actor wakes again on the same request and makes the call with the approval.
A message the actor may not act on gets no answer, so nobody outside the rules learns that an agent sits in the space. An organization admin sees why in data.buzz.inbound.list: the reason is REQUESTER_REQUIRED:<code>, with a code from the table below.
Path B: the harness contract
Your harness reads the person's message from the chat, then starts the workflow with the agent's agt_ token and the message itself under the reserved key _orkestia_requested_by:
curl -X POST https://workflow-api.orkestia.dev/api/workflows/start \
-H "Authorization: Bearer agt_…" \
-H "Content-Type: application/json" \
-d @start.json
{
"workflow_type": "virtual.<composition_uuid>@3",
"initial_data": {
"customer_id": "C-1042",
"_orkestia_requested_by": {
"kind": "buzz_event",
"space_uuid": "<space uuid>",
"event": {
"id": "<64 hex>",
"pubkey": "<the person's key, 64 hex>",
"created_at": 1793782800,
"kind": 9,
"tags": [
["h", "<channel id>"],
["p", "<your agent's bound key, 64 hex>"]
],
"content": "@Sales agent what is the status of C-1042?",
"sig": "<128 hex>"
}
}
}
}
| Rule | Detail |
|---|---|
| Credential | Only an agt_ token may send the key. Any other caller is refused (REQUESTER_REQUIRES_AGENT) |
| The event | The person's message exactly as signed: all seven fields, unchanged. Any edit breaks the signature (REQUESTER_BAD_SIGNATURE) |
| Channel and mention | One h tag with the channel. A p tag with your agent's bound key |
| Keys | kind, space_uuid, event, and approval_uuid for a write. Any other key is refused (REQUESTER_MALFORMED) |
| Age | At most 5 minutes old for a read and for asking an approval, unless a live task lease covers the request |
| Never sent to the workflow | The key is removed before the input is validated. Your composition never sees it, and the approval digest is computed without it |
| Do not name the requester yourself | requested_by, _requested_by or similar keys in the input are refused (REQUESTER_OVERRIDE_REJECTED). The requester comes only from the signed message |
| Version | The platform starts the exact composition version it checked. When that differs from the type you sent (an ORN, for example), the answer says so in requested_workflow_type |
A refused start answers 403 (503 when the check is unavailable) with the code:
{
"code": "REQUESTER_NOT_GRANTED",
"message": "The requester may not ask this agent for this start.",
"error": "ForbiddenError"
}
To ask before acting, call identity.requester.check with request_reference (the same object as _orkestia_requested_by) and workflow_type. It answers allowed, code, served_workflow_type, read_only and needs_approval, and spends nothing. It is advice only: the start is checked again.
Before you post an answer, check the person again. Your harness posts its answers to the chat itself, so the platform never sees them before they appear. Call identity.requester.check with the request and the composition the answer came from, and post only when the answer is allowed: true. On any other code, post nothing in that channel. This is your harness's side of the contract: the platform cannot enforce it, because your harness signs its own messages. See When a person loses access.
Writes: the approval flow
- Ask. The agent calls
identity.requester.approval.requestwithrequest_reference,workflow_type, the exactinputit will send, and optionallylocale(pt-BRoren). The person must be allowed to run the composition, or the answer isstatus: "refused"with a code and no card is made. - Post the card. The answer has
status: "pending",approval_uuid,served_workflow_type,expires_at(10 minutes), thechannelto post in, and the card. On path A the agent postsreply_textas its answer. On path B, postcard.textin that channel with one["buzz-quick-reply", "<label>"]tag for each entry ofcard.quick_replies. Values whose names look sensitive (passwords, tokens, tax ids) are masked on the card; the approval still covers their real values. - The person taps. The buttons are
rq:<approval_uuid>:approveandrq:<approval_uuid>:deny. A tap is the person's own signed message in that channel. It counts only from the requester, while the approval is pending and unexpired.- Path A: the platform chat records the tap and wakes the agent; nothing to do.
- Path B: your harness receives the tap. Forward it unchanged to
identity.requester.approval.record-tapwithspace_uuidandevent(the full signed tap;approval_uuidis optional and must match the token). The answer isrecorded(true or false),status(approved,deniedorrefused) and, when refused,codeandreason. The person's signature authorizes the tap, not your harness, and only the agent the approval was issued to can record it. Recording the same tap twice answersREQUESTER_APPROVAL_USED.
- Start. Start
served_workflow_typewith the identical input andapproval_uuidadded to_orkestia_requested_by. Changing any value refuses the start (REQUESTER_APPROVAL_DIGEST_MISMATCH). The approval is spent by that start, even if the run then fails: ask again for a retry.
"_orkestia_requested_by": {
"kind": "buzz_event",
"space_uuid": "<space uuid>",
"event": { "…": "the same request message" },
"approval_uuid": "<approval_uuid>"
}
Before the tap, the start is refused with REQUESTER_APPROVAL_PENDING; after deny, with REQUESTER_APPROVAL_DENIED.
Long tasks
identity.requester.lease.request is not in your catalog, and the windows in Two ways a request arrives apply as they are.One request message lets an agent read for a short time: 15 minutes on path A, 5 minutes and 20 read starts on path B. Work that lasts longer has three options. The windows themselves are never raised.
A task lease: one tap for up to 2 hours. Inside the request's window, the agent asks the person to let the task keep reading:
start_workflow("identity.requester.lease.request", {
"organization_uuid": "<your organization>",
"request_reference": { "kind": "buzz_event", "space_uuid": "<space uuid>", "event": { "…": "the request message" } },
"minutes": 120,
"purpose": "Analysing the 40 contracts you sent",
"locale": "en"
})
- The answer has the same shape as an approval request:
status: "pending",code: "LEASE_PENDING",lease_uuid,minutes,expires_at(10 minutes to tap), thechannel,card,quick_repliesandreply_text. Post the card as you post an approval card. minutesis 1 to 120. The lease runs from the person's tap, not from the request.- It must be asked inside the request's window, by an agent the person may still ask. A late ask answers
REQUESTER_REQUEST_EXPIRED. - Path A: the agent asks for it itself when a task needs more time, and posts the card as its answer. When the person taps, the agent continues.
- Path B: forward the person's tap to
identity.requester.approval.record-tapexactly like an approval tap (approval_uuidis thelease_uuid), then keep sending the same_orkestia_requested_by. - While the lease lives, read-only starts under that request are allowed after the window ends, and path B may make up to 500 read starts instead of 20.
- Writes still need a tap each. A lease never approves a write.
- The lease ends at its time, or at once when the person loses access.
A new message from the person. On path B, send the person's newer signed message: it opens a new window. On path A, a new message from the same person in the same conversation also gives the agent's work still running for them a fresh 15 minutes.
One composition for the whole task. Steps started by a run the request allowed are not limited by the window: one exposed composition started inside it runs to the end. Use this when the steps are known in advance.
When a person loses access
A person loses access when they are disabled or signed out by an admin, lose their seat or app grant, leave the workspace, or are removed from or suspended in the space. What stops, and when:
| What | When it stops |
|---|---|
| A new request, or a new composition started for them | At once: every start checks the person again |
| A step that may change data, inside work already running | At its next such step: the step is refused and the run fails with the reason (REQUESTER_INACTIVE, REQUESTER_NOT_MEMBER, REQUESTER_NOT_IN_WORKSPACE, ...) |
| Work that only reads, task leases and agent sessions working for them | Within seconds: the platform cancels them |
| The agent's answer in the platform chat (path A) | Never posted. An organization admin sees requester_revoked:<code> in data.buzz.inbound.list |
| Your harness's answer (path B) | Only if your harness checks before posting, as described in the harness contract |
Path B: stop work you hold outside the platform. The platform cannot cancel work your harness does on its own. Check the person before each answer with identity.requester.check. When a person is removed from or suspended in the space, their key also leaves the space's membership list (kind 13534, signed by the chat server), so a harness subscribed to that list can stop their work at once. Other ways of losing access (an admin disabling the person, for example) are not announced in the chat yet: your next start or check is refused instead.
What the run records
metadata.requested_byon the run and on every step below it: the requester's end user, app, workspace, space, the request message id, and the approval when there was one. No step can change or drop it.- The run's actor is the agent, and
triggered_byisagent:<staff actor uuid>, whatever the caller sent. - Rows the run writes in App Data are owned by the agent, never by the requester.
- Refused starts are written to your audit log as
identity.requester.refused, sampled to about one entry a minute per agent and code.
Refusal codes
The request message
| Code | Meaning | What to do |
|---|---|---|
REQUESTER_MALFORMED | The reference or event has the wrong shape: unknown keys, not a chat message, missing fields | Send kind, space_uuid, event (and approval_uuid) only, with the full event |
REQUESTER_BAD_SIGNATURE | The event id or signature does not verify | Forward the event exactly as it came from the chat |
REQUESTER_SPACE_UNAVAILABLE | The space is not active, or belongs to another organization | Use a space of the agent's organization |
REQUESTER_WRONG_CHANNEL | Not exactly one channel tag, an archived channel, or a tap outside the approval's channel | Use the message's own channel; post the card where channel says |
REQUESTER_AGENT_KEY_UNBOUND | The agent has no key in this space: not attached (path A) or no bound key (path B) | Attach the actor, or bind its key with buzz.org-agent-key.bind-actor |
REQUESTER_NOT_ADDRESSED | The message does not mention this agent | The person must mention the agent |
REQUESTER_STALE_EVENT | The message is older than the window or dated in the future, or a tap falls outside the approval's life | Ask the person to send the request again |
REQUESTER_NOT_MEMBER | The author is not a member of the space | Add the person to the space |
REQUESTER_NOT_HUMAN | The author is an actor, an organization agent key, a relay bot, or the agent itself | Only people can be requesters |
REQUESTER_INACTIVE | The person, their seat, account or app grant is not active, or they were signed out after the message | Restore the person's access, then ask them to send the request again |
REQUESTER_EVENT_CLAIMED | Another agent already used this message | Each agent answers its own mentions |
REQUESTER_WRONG_ORGANIZATION | The run's organization is not the requester's | Start in the organization that owns the space |
The workflow and the requester
| Code | Meaning | What to do |
|---|---|---|
REQUESTER_NOT_EXPOSED | Not a composition your app exposes, or not the version it serves (served_workflow_type says which) | Expose it, or start the served version |
REQUESTER_NOT_AGENT_ENABLED | The exposure is not enabled for agents | A person runs identity.exposure.set-agent-policy with agent_on_behalf: true |
REQUESTER_NOT_GRANTED | No group of the app explicitly grants this composition | Grant vw:<lineage_uuid>:invoke to the right groups |
REQUESTER_NOT_PERMITTED | Groups are granted it, but not the requester's | Move the person to a granted group, or tell them they cannot |
REQUESTER_NOT_IN_WORKSPACE | The person is not an active member of the request's workspace, or the workspace is inactive | Add them to the workspace |
REQUESTER_RAW_TYPE_REFUSED | A raw catalog workflow or another tool was called for the requester | Wrap the step in an exposed composition |
REQUESTER_READ_ONLY_VIOLATION | A read-only run tried to start a write | Mark the step read-only only if it is; otherwise ask for an approval |
REQUESTER_REQUEST_EXPIRED | The read window is over or the read budget (path B: 20) is spent, or a task lease was asked after the window | Ask the person to send the request again. For long work, ask for a task lease inside the window |
Approvals
| Code | Meaning | What to do |
|---|---|---|
REQUESTER_APPROVAL_REQUIRED | A write with no approval | Call identity.requester.approval.request and wait for the tap |
REQUESTER_APPROVAL_PENDING | The card has not been tapped yet | Wait for the tap; do not start again until then |
REQUESTER_APPROVAL_DENIED | The person tapped deny | Do not retry. Ask the person what they want instead |
REQUESTER_APPROVAL_EXPIRED | Past the 10 minutes | Ask for a new approval |
REQUESTER_APPROVAL_USED | Already spent by an earlier start | Ask for a new approval for each start |
REQUESTER_APPROVAL_INVALID | Unknown approval, or one for another agent, person, request, composition or version | Use the approval_uuid and served_workflow_type from your own request |
REQUESTER_APPROVAL_DIGEST_MISMATCH | The input differs from the approved input | Send exactly the input you asked approval for |
REQUESTER_APPROVER_MISMATCH | Someone other than the requester tapped | Only the requester can approve |
The agent and the call
| Code | Meaning | What to do |
|---|---|---|
REQUESTER_REQUIRES_AGENT | The caller is not a Staff agent's agt_ token or hosted session | Call with the agent's own credential |
REQUESTER_AGENT_INELIGIBLE | The agent is not active, has no live identity or token, or is not on a paid RBAC-scoped seat | Fix the actor's seat and token |
REQUESTER_REQUIRED | This actor must name a requester and none was given or resolved | Send _orkestia_requested_by, or have the person mention the actor |
REQUESTER_OVERRIDE_REJECTED | The input tries to set the requester, or a hosted session sent a second one | Remove the field; the requester comes only from the message |
REQUESTER_SESSION_MISMATCH | Someone other than the requester messaged a requester-bound session | The other person starts their own request |
REQUESTER_ORG_APPROVAL_UNSUPPORTED | The org's tool policy sends this call to a reviewer, which cannot apply to a call made for a person | Allow that workflow for the agent in the tool policy |
REQUESTER_START_NOT_RECORDED | The start was not recorded against its approval or read before it ran | Start again through the normal start call |
REQUESTER_UNAVAILABLE, REQUESTER_CHECK_UNAVAILABLE, REQUESTER_CHECK_FAILED | The check could not run, so the start was refused | Retry later. If it persists, contact support |
Not supported yet
- Person- and workspace-owned tables. Runs made for a requester reach only the app-owned App Data tables granted to the agent. Tables owned by a person or a workspace stay closed to them, so the agent can reach neither the requester's own rows nor anyone else's. Writes that land in such tables are refused until a narrowing design ships.
- Read-only exposures are rare. See the warning above: most actions need a tap until more catalog workflows declare that they have no side effects.
- Approval card preparation. On path A the agent waits up to 120 seconds for the card to be prepared. If it is not ready in time, nothing is done and the person can ask again.
- Approvals by someone else. Only the requester approves. An app admin cannot approve for another person.
- Other work by a requester-required actor. Such an actor works only for people. Its sessions started by a schedule or a Staff event, with no person behind them, cannot call tools.
- Who asked, on rows. App Data rows do not record the requester yet; the run does.
Ask your AI assistant
Check whether the person who sent this chat message may have my agent run virtual.<uuid>@<version>. Use identity.requester.check with the message as request_reference and tell me the code.
List the compositions my app exposes and which of them have agent_on_behalf on. Prepare (do not run) the identity.exposure.set-agent-policy call for the one called "<name>".
My agent got REQUESTER_NOT_PERMITTED for "<composition>". Find the group that grants vw:<lineage_uuid>:invoke and the group of the requester, and explain the gap. Do not change anything.
For AI agents
| Rule | Detail |
|---|---|
| Who you are | You run as your own actor. Never ask for or use a person's token |
| Human only | identity.exposure.set-agent-policy, staff.set-actor-requester-required and buzz.org-agent-key.bind-actor are refused for agents: each checks who started it and answers a permission error (identity_admin_requires_human for the identity one) to an agent, an agent session or an API key. Prepare the call; a person starts it |
| Writes | Call identity.requester.approval.request, post its card, wait for the tap (path B: forward it to identity.requester.approval.record-tap), then start with the identical input and approval_uuid. Never treat a typed "ok" as approval |
| Retries | REQUESTER_APPROVAL_DENIED, REQUESTER_NOT_PERMITTED and REQUESTER_NOT_GRANTED do not change on retry. Tell the person |
| Expired | REQUESTER_REQUEST_EXPIRED or REQUESTER_STALE_EVENT: ask the person to send the request again |
| Long tasks | Ask identity.requester.lease.request inside the window, post its card, and wait for the tap. A lease never approves a write |
| Before answering (path B) | Call identity.requester.check; post nothing unless allowed is true |
| Raw tools | For a requester, use exposed compositions only |
Media actors
Staff actors that make videos and posts. They fill existing templates, render them and send everything for review; they never generate images or video with a model
App Host
Host your Orkestia app — website, App Data, Files on site MinIO, and a Nostr Buzz relay — from the console, without running your own cluster.
