Skip to content
Subako Docs
English
Esc
↑↓navigate↵open⌘Jpreview
On this page

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" } }
      ]
    }
  }'
  • answered carries the value. skipped is for a question that is not required.
  • unsupported says 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.

The console showing a form above the composer

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.

Was this page helpful?