Guides
Answer approvals from your program
Notice a task that waits for an Allow, read what it asks, and allow or deny it from your program with a key made with Can allow actions.
An approval is a worker asking before it acts. The task stops until someone answers. Approvals explains which actions ask and who may answer. A person can answer every approval in the app. This guide is for a program that answers some of them for you, by a rule you write, such as allowing a worker to save a note in one folder and refusing everything else.
Each step shows curl, TypeScript and Python. Pick one and the page keeps it picked. The TypeScript and Python steps build one small program, so add each step's code to the same file.
Before you start
- A key made with Can allow actions turned on. In Settings, then API Keys, choose Create Key, name it, turn the switch on, and choose Create. A key without it gets
403withperson_requiredwhen it answers. API keys says what the switch opens. - The key in
LITTLE_WORKER_API_KEY. - For curl: curl and jq.
- For TypeScript: Node 22.18 or later, which runs a
.tsfile as it is. Save the program asapprovals.tsand runnode approvals.ts. - For Python: Python 3 and the requests library (
pip install requests). Save the program asapprovals.pyand runpython3 approvals.py.
Step 1: Make a worker that asks first
A worker asks before a step when its tool group is set to always_ask. This worker keeps notes, and asks before it writes one. Every other group that changes things asks too, unless you set it otherwise.
curl https://core.thelittleworker.com/api/v1/workers \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19" \
-H "content-type: application/json" \
-d '{
"name": "Note keeper",
"system": "You keep notes for a small shop. When the person tells you something to remember, save it as a note with your memory tools, at a path that starts with /shop/. Then say what you saved.",
"tools": [
{
"type": "worker_toolset_20260401",
"configs": [{ "name": "memories", "permission_policy": { "type": "always_ask" } }]
}
]
}'const api = "https://core.thelittleworker.com/api/v1"
const headers = {
"x-api-key": process.env.LITTLE_WORKER_API_KEY ?? "",
"Little-Worker-Version": "2026-10-19",
"content-type": "application/json",
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(`${api}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`)
}
return (await response.json()) as T
}
const worker = await call<{ id: string }>("POST", "/workers", {
name: "Note keeper",
system:
"You keep notes for a small shop. When the person tells you something to remember, save it as a note with your memory tools, at a path that starts with /shop/. Then say what you saved.",
tools: [
{
type: "worker_toolset_20260401",
configs: [{ name: "memories", permission_policy: { type: "always_ask" } }],
},
],
})
console.log(worker.id)import json
import os
import time
import requests
api = "https://core.thelittleworker.com/api/v1"
headers = {
"x-api-key": os.environ["LITTLE_WORKER_API_KEY"],
"Little-Worker-Version": "2026-10-19",
}
def call(method, path, body=None):
response = requests.request(method, f"{api}{path}", headers=headers, json=body)
if not response.ok:
raise RuntimeError(f"{method} {path}: {response.status_code} {response.text}")
return response.json()
worker = call(
"POST",
"/workers",
{
"name": "Note keeper",
"system": "You keep notes for a small shop. When the person tells you something to remember, save it as a note with your memory tools, at a path that starts with /shop/. Then say what you saved.",
"tools": [
{
"type": "worker_toolset_20260401",
"configs": [
{"name": "memories", "permission_policy": {"type": "always_ask"}}
],
}
],
},
)
print(worker["id"])The answer is the new worker, and its tools show the policy of every group. With curl, keep its id:
export WORKER_ID="worker_bf5ff997fca044b38dc1cc9ea5b60100"Step 2: Give it a task
Ask for something that needs a note. The worker will try to write one, and the task will stop before it does.
curl https://core.thelittleworker.com/api/v1/tasks \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19" \
-H "content-type: application/json" \
-d '{
"worker": "'"$WORKER_ID"'",
"title": "Shop hours",
"initial_events": [
{
"type": "user.message",
"content": [{ "type": "text", "text": "Remember that the shop now opens at 7 am." }]
}
]
}'type Step = { type: string; event_id: string | null; person_only: boolean }
type Task = {
id: string
status: string
state: { status: string; needs: string | null }
stop_reason: { type: string; blocking?: Step[] } | null
}
const task = await call<Task>("POST", "/tasks", {
worker: worker.id,
title: "Shop hours",
initial_events: [
{
type: "user.message",
content: [{ type: "text", text: "Remember that the shop now opens at 7 am." }],
},
],
})
console.log(task.id)task = call(
"POST",
"/tasks",
{
"worker": worker["id"],
"title": "Shop hours",
"initial_events": [
{
"type": "user.message",
"content": [
{"type": "text", "text": "Remember that the shop now opens at 7 am."}
],
}
],
},
)
print(task["id"])With curl, keep the task's id:
export TASK_ID="task_01M5TTRJKWYAK3XHFX432K5MBV"Step 3: Notice that it waits
A task that waits for an Allow stops as idle. Its state.needs is allow, and its stop_reason is requires_action. Read the task until it stops:
curl -s https://core.thelittleworker.com/api/v1/tasks/$TASK_ID \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19" \
| jq '{status, state, stop_reason}'const current = await call<Task>("GET", `/tasks/${task.id}`)
console.log(current.status, current.state, current.stop_reason)current = call("GET", f"/tasks/{task['id']}")
print(current["status"], current["state"], current["stop_reason"])Once the worker has reached the note, the answer looks like this:
{
"status": "idle",
"state": { "status": "needs_you", "needs": "allow", "waiting_reason": null },
"stop_reason": {
"type": "requires_action",
"event_ids": ["event_3b7e1a9c5d2f4e8a9c0b1d2e3f4a5b6c"],
"blocking": [
{
"type": "approval",
"event_id": "event_3b7e1a9c5d2f4e8a9c0b1d2e3f4a5b6c",
"person_only": false
}
]
}
}event_idsholds the id of eachworker.tool_useevent that asks. That id is thetool_use_idyou answer with, and it never changes, even when the same step is asked again.blockinglists every step the task waits on, in the order they were asked. Anapprovalis an Allow for a tool or an app action.person_onlyistruefor a step only a person can answer, in the app. A key that tries gets403withperson_required. Leave those for the person.
blocking can list other kinds of step. A custom_tool_use is a call to one of the worker's own tools, which your code answers with user.custom_tool_result. The rest are cards in the app, such as a question or a form, and are always person_only.
You can also learn that a task waits without polling it. The stream sends a task.status_idle event with requires_action (Follow a task as it works), a webhook sends the same notice (Webhooks), and GET /tasks?status=needs_you lists every task that needs you, so you can look for state.needs allow.
Step 4: Read what it asks
The id in event_ids names a worker.tool_use event, or a worker.mcp_tool_use event for an app. The event holds the tool's name and its input, which is what you decide on. List the task's tool uses and keep the ones that wait:
curl -s -g "https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events?types[]=worker.tool_use&types[]=worker.mcp_tool_use&order=desc&limit=100" \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19" \
| jq '.data[] | {id, name, input, evaluated_permission}'type ToolUse = { id: string; name: string; input: { path?: string } }
async function asked(taskId: string, ids: string[]): Promise<ToolUse[]> {
const list = await call<{ data: ToolUse[] }>(
"GET",
`/tasks/${taskId}/events?types[]=worker.tool_use&types[]=worker.mcp_tool_use&order=desc&limit=100`,
)
return list.data.filter((use) => ids.includes(use.id))
}def asked(task_id, ids):
events = call(
"GET",
f"/tasks/{task_id}/events?types[]=worker.tool_use&types[]=worker.mcp_tool_use&order=desc&limit=100",
)
return [use for use in events["data"] if use["id"] in ids]Shortened, the event looks like this. evaluated_permission is ask and evaluation says why: always_ask is the setting you made.
{
"type": "worker.tool_use",
"id": "event_3b7e1a9c5d2f4e8a9c0b1d2e3f4a5b6c",
"task_id": "task_01M5TTRJKWYAK3XHFX432K5MBV",
"sequence": "6",
"processed_at": "2026-10-25T22:07:41.318Z",
"name": "memory.write",
"input": { "path": "/shop/hours.md", "content": "The shop now opens at 7 am." },
"evaluated_permission": "ask",
"evaluation": { "type": "always_ask" }
}A password the worker uses never shows in input: it is masked.
Step 5: Allow or deny it
Write the rule, then answer with a user.approval event (Send events). result is allow or deny. A denial may carry a deny_message, up to 10,000 characters, and the worker reads it as the reason the action failed. A message with allow is refused with 400.
curl https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19" \
-H "content-type: application/json" \
-d '{
"events": [
{
"type": "user.approval",
"tool_use_id": "event_3b7e1a9c5d2f4e8a9c0b1d2e3f4a5b6c",
"result": "allow"
}
]
}'type Verdict =
| { result: "allow" }
| { result: "deny"; deny_message: string }
// Your rule: allow a note under /shop/, and refuse everything else.
function decide(use: ToolUse): Verdict {
if (use.name === "memory.write" && use.input.path?.startsWith("/shop/")) {
return { result: "allow" }
}
return { result: "deny", deny_message: "Only notes under /shop/ are allowed here." }
}
async function answer(taskId: string, use: ToolUse) {
const sent = await call<{ data: { status: string }[] }>(
"POST",
`/tasks/${taskId}/events`,
{ events: [{ type: "user.approval", tool_use_id: use.id, ...decide(use) }] },
)
console.log(use.name, JSON.stringify(use.input), "->", sent.data[0]?.status)
}def decide(use):
"""Your rule: allow a note under /shop/, and refuse everything else."""
path = use["input"].get("path", "")
if use["name"] == "memory.write" and path.startswith("/shop/"):
return {"result": "allow"}
return {"result": "deny", "deny_message": "Only notes under /shop/ are allowed here."}
def answer(task_id, use):
sent = call(
"POST",
f"/tasks/{task_id}/events",
{"events": [{"type": "user.approval", "tool_use_id": use["id"], **decide(use)}]},
)
print(use["name"], json.dumps(use["input"], separators=(",", ":")), "->", sent["data"][0]["status"])The answer is 200, with one entry in data for the event you sent. status is allowed or denied:
{
"data": [
{
"type": "user.approval",
"tool_use_id": "event_3b7e1a9c5d2f4e8a9c0b1d2e3f4a5b6c",
"status": "allowed"
}
]
}Every answer, yours or a person's in the app, is listed in the task's events as a user.approval. For yours, api_key_id is the key that answered. For a person's, it is null.
A key answers one action at a time. It cannot choose to remember an answer, so it can never loosen a setting.
Step 6: Go on until the task is done
After an answer the task runs again and may stop for another Allow, so keep looping. This loop reads the task, answers whatever waits, and ends when the task stops for any other reason. It skips an id it already answered, because the task can still show its old stop for a moment after you answer.
async function handle(taskId: string): Promise<Task> {
const answered = new Set<string>()
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 2000))
const current = await call<Task>("GET", `/tasks/${taskId}`)
if (current.status !== "idle") continue
const stop = current.stop_reason
if (stop?.type !== "requires_action") return current
const open = (stop.blocking ?? []).filter(
(step) => step.event_id === null || !answered.has(step.event_id),
)
if (open.some((step) => step.type !== "approval" || step.person_only)) {
console.log("A step waits for a person, in the app.")
return current
}
const ids = open.flatMap((step) => (step.event_id === null ? [] : [step.event_id]))
for (const use of await asked(taskId, ids)) {
await answer(taskId, use)
answered.add(use.id)
}
}
}
const done = await handle(task.id)
console.log("Stopped:", done.stop_reason?.type, done.state.status)def handle(task_id):
answered = set()
while True:
time.sleep(2)
current = call("GET", f"/tasks/{task_id}")
if current["status"] != "idle":
continue
stop = current["stop_reason"]
if stop is None or stop["type"] != "requires_action":
return current
open_steps = [
step
for step in stop["blocking"]
if step["event_id"] is None or step["event_id"] not in answered
]
if any(step["type"] != "approval" or step["person_only"] for step in open_steps):
print("A step waits for a person, in the app.")
return current
ids = [step["event_id"] for step in open_steps if step["event_id"] is not None]
for use in asked(task_id, ids):
answer(task_id, use)
answered.add(use["id"])
done = handle(task["id"])
print("Stopped:", (done["stop_reason"] or {}).get("type"), done["state"]["status"])After the lines the earlier steps print, the program prints each answer and then the stop:
memory.write {"path":"/shop/hours.md","content":"The shop now opens at 7 am."} -> allowed
Stopped: end_turn doneIf your rule denies a step, the worker reads the reason, and usually tries something else, which may ask again. A task keeps stopping for as long as the worker keeps asking, so a program that must end should count its answers and take the task back after a few (Stop work and keep history).
See every approval that waits
GET /approvals (List approvals) lists the approvals that wait for an answer across all your tasks, newest first. It lists app actions and steps on a Mac. It leaves out the steps only a person answers, and the steps a worker's tool group setting asks, such as this guide's note, which a task's requires_action stop always names.
curl https://core.thelittleworker.com/api/v1/approvals \
-H "x-api-key: $LITTLE_WORKER_API_KEY" \
-H "Little-Worker-Version: 2026-10-19"Each approval has these fields. GET /approvals/{approval_id} reads one in any status, which the list does not show once it is answered:
| Field | What it holds |
|---|---|
id |
The approval's id, starting with approval_. |
task_id, worker_id |
The task that waits and its worker. |
tool_use_id |
The id to answer with, the worker.tool_use event that asks. |
tool_name, input |
What the worker wants to do. A Mac action's command and environment are never shown. |
status |
waiting, allowed, denied, expired or used. |
created_at, expires_at, decided_at |
When it was asked, when its time ends, and when it was answered. |
For an app action or a tool group's ask, expires_at does not end the wait. Answer late, by tool_use_id, and the step still runs. A step on a Mac has a deadline: after it, the step fails with approval_expired and the task stops again for what still waits.
When an answer is refused
| Status | error_code |
When it happens |
|---|---|---|
| 403 | person_required |
The key was made without Can allow actions, or only a person may answer this step. |
| 403 | not_task_starter |
On a shared workspace, the task was started by someone else. A key answers only the approvals of tasks its own person started. |
| 404 | not_found |
No approval is asked for that tool_use_id in this task. |
| 409 | conflict |
The approval already has the other answer, or the server took it back. |
| 409 | approval_consumed |
The step was already allowed and has run. |
| 409 | approval_expired |
A step on a Mac passed its deadline. |
| 400 | validation_error |
A deny_message came with allow, or is over 10,000 characters. |
Answering the same way twice before the step runs is safe: the second answer changes nothing.
Next steps
- Approvals: which actions ask, and the steps only a person can answer.
- Approvals reference: list and read approvals.
- Follow a task as it works: see a task stop with
requires_actionthe moment it does. - Webhooks: have Little Worker tell your server when a task needs an answer.
- Stop work and keep history: end a task whose worker keeps asking.