# 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](/concepts/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 `403` with `person_required` when it answers. [API keys](/get-started/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 `.ts` file as it is. Save the program as `approvals.ts` and run `node approvals.ts`.
- For Python: Python 3 and the requests library (`pip install requests`). Save the program as `approvals.py` and run `python3 approvals.py`.

> [!WARNING]
> A key that can allow actions can let a worker act for you. Write a rule that allows only what you expect and denies the rest, and keep the key where a worker cannot read it.

## 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.

```bash title="curl"
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" } }]
      }
    ]
  }'
```

```ts title="TypeScript"
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)
```

```python title="Python"
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:

```bash
export WORKER_ID="worker_bf5ff997fca044b38dc1cc9ea5b60100"
```

## 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.

```bash title="curl"
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." }]
      }
    ]
  }'
```

```ts title="TypeScript"
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)
```

```python title="Python"
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:

```bash
export TASK_ID="task_01M5TTRJKWYAK3XHFX432K5MBV"
```

## 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:

```bash title="curl"
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}'
```

```ts title="TypeScript"
const current = await call<Task>("GET", `/tasks/${task.id}`)
console.log(current.status, current.state, current.stop_reason)
```

```python title="Python"
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:

```json
{
  "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_ids` holds the id of each `worker.tool_use` event that asks. That id is the `tool_use_id` you answer with, and it never changes, even when the same step is asked again.
- `blocking` lists every step the task waits on, in the order they were asked. An `approval` is an Allow for a tool or an app action.
- `person_only` is `true` for a step only a person can answer, in the app. A key that tries gets `403` with `person_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](/guides/follow-a-task)), a webhook sends the same notice ([Webhooks](/guides/webhooks)), and `GET /tasks?status=needs_you` lists every task that needs you, so you can look for `state.needs` `allow`.

## 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:

```bash title="curl"
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}'
```

```ts title="TypeScript"
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))
}
```

```python title="Python"
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.

```json
{
  "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.

## 5. Allow or deny it

Write the rule, then answer with a `user.approval` event ([Send events](/api/events#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`.

```bash title="curl"
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"
      }
    ]
  }'
```

```ts title="TypeScript"
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)
}
```

```python title="Python"
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`:

```json
{
  "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.

## 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.

```ts title="TypeScript"
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)
```

```python title="Python"
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:

```text
memory.write {"path":"/shop/hours.md","content":"The shop now opens at 7 am."} -> allowed
Stopped: end_turn done
```

If 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](/guides/stop-and-retention)).

## See every approval that waits

`GET /approvals` ([List approvals](/api/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.

```bash
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](/concepts/approvals): which actions ask, and the steps only a person can answer.
- [Approvals reference](/api/approvals): list and read approvals.
- [Follow a task as it works](/guides/follow-a-task): see a task stop with `requires_action` the moment it does.
- [Webhooks](/guides/webhooks): have Little Worker tell your server when a task needs an answer.
- [Stop work and keep history](/guides/stop-and-retention): end a task whose worker keeps asking.
