Approvals and forms
Approve tool calls and answer forms from your own UI.
Some decisions are not the model’s to make: issuing a refund, sending an email, choosing between two plans the customer has to pay for. Subako gives an agent two ways to wait for a person, and both are a pair of events on the session’s log — so they work the same whether the person is watching live or opens the session an hour later.
| Approval | Form | |
|---|---|---|
| Asked by | A tool whose policy is Ask first | The agent itself, when its version lets it ask people |
| The person decides | Whether this call runs | The answers to up to four questions |
| Events | approval_requested → approval_resolved |
input_requested → input_resolved |
| Settled at | POST /v1/sessions/{id}/approvals/{call_id} |
POST /v1/sessions/{id}/inputs/{call_id} |
| Nobody answers in 24 hours | Denied, with “approval timed out” | Expired; the model is told nobody answered |
Both routes take the session token or a key with Sessions › Manage. The console settles them the same way.
Approvals
When a gated call comes up, the run appends approval_requested and waits:
| Field | |
|---|---|
call_id |
The call being decided |
server |
The grant it belongs to, such as mcp:orders, fn:billing, or sandbox:build |
tool |
The tool’s name |
arguments |
What the model wants to call it with |
args_hash |
What a call fired again is checked against |
Show the person arguments as they are: the point of an approval is that a person sees the real call. Then settle it:
curl -X POST https://api.us.cloud.subako.ai/v1/sessions/$SESSION_ID/approvals/$CALL_ID \
-H "Authorization: Bearer $SUBAKO_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"decision": "deny", "message": "Refunds over $500 need a manager."}'
On allow the call runs and its tool_result follows. On deny the model is told — with your message when you give one — and the run continues. A message belongs to deny alone; sending one with allow is refused, so an approval cannot carry text the model never sees.
The answer is the approval_resolved event. A call with nothing pending answers 404, and one already settled 409.
A denial is a normal outcome, not an error, which is why the message is worth writing: “not approved” leaves the model guessing; “refunds over $500 need a manager” tells it what to do next.
Forms
An agent version that may ask people (builtin.ask_user in the agent config) gives the model a tool to put a form. The run appends input_requested:
{
"type": "input_requested",
"call_id": "call_8f2a",
"request": {
"questions": [
{
"id": "day",
"prompt": "Which day works for you?",
"required": true,
"widget": { "type": "select", "data": { "options": [{ "label": "Tue" }, { "label": "Wed" }], "multiple": false, "allow_other": false } }
},
{ "id": "seating", "prompt": "Any seating preference?", "required": false, "widget": { "type": "text", "data": { "multiline": false } } }
]
}
}
| Widget | Draws | Its answer |
|---|---|---|
select |
Options to pick, one or (multiple) several, maybe with a free-text Other (allow_other) |
An array of the chosen labels |
text |
A text field, one line or multiline |
A string |
confirm |
Yes or no | A boolean |
custom |
A widget of your own app’s, by its id, with the props the agent filled in |
Whatever its answer schema says |
Answer every question once:
curl -X POST https://api.us.cloud.subako.ai/v1/sessions/$SESSION_ID/inputs/$CALL_ID \
-H "Authorization: Bearer $SUBAKO_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "submitted",
"data": {
"answers": [
{ "question_id": "day", "outcome": { "type": "answered", "data": { "value": ["Tue"] } } },
{ "question_id": "seating", "outcome": { "type": "skipped" } }
]
}
}'
answeredcarries the value.skippedis for a question that is not required.unsupportedsays your client cannot draw that widget — a custom one it does not know, say. It is always allowed, and the model is told to ask another way.{"type": "declined", "data": {"message": "Not now."}}dismisses the whole form; the model reads that the person chose not to answer, with your message.
The answer is the input_resolved event. An answer that does not fit a question is refused with the reason, and the form stays open.

Custom widgets
A custom widget is declared in the agent version, with the schemas of what the agent fills in and what an answer holds:
{
"builtin": {
"ask_user": {
"widgets": [
{ "type": "select" },
{ "type": "confirm" },
{
"type": "custom",
"id": "seat_picker",
"description": "Pick seats on the venue map.",
"props": { "type": "object", "properties": { "venue": { "type": "string" } } },
"answer": { "type": "array", "items": { "type": "string" } }
}
]
}
}
}
The model reads description to choose the widget, and fills in props; your app draws it and answers with a value that matches answer.