> ## Documentation Index
> Fetch the complete documentation index at: https://docs.matrices.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Approve a tool call

> Hold a write until a human says yes.

Some calls shouldn't happen without a person watching. You'll hold every write
your agent tries to make over HTTP, find it waiting, and either let it through
or turn it down with a reason that lands in the thread's log.

## 1. Add the hook

A [hook](/model/hooks) at `before_tool_call` sees the tool name and its input
before the tool runs. A `client` processor means "stop and ask my application":

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl --fail --silent --show-error -X PATCH \
  "https://api.matrices.com/v1/profiles/$PROFILE_ID" \
  -H "Authorization: Bearer $MATRICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "configuration": {
      "hooks": [
        {
          "id": "approve-writes",
          "point": "before_tool_call",
          "match": {
            "tools": ["http.request"],
            "where": [{ "path": "input.method", "op": "in", "value": ["POST", "PUT", "PATCH", "DELETE"] }]
          },
          "processor": { "type": "client" },
          "onFailure": "fail_closed"
        }
      ]
    }
  }'
```

A hook doesn't grant anything. Roles are checked first, so a tool the roles
leave out never reaches the hook. If you came from
[Narrow an agent's permissions](/guides/narrow-permissions), that role only
allows `http.request` for `GET`. Drop the `where` from that entry; the hook is
what stops writes now, and it can ask you instead of refusing outright. Also,
the hook list you send replaces the whole list, so the six
[default approvals](/reference/hooks#default-hooks) are gone unless you send
them again.

## 2. Trigger it

Ask the agent for something it can only do by writing:

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
export THREAD_ID="$(
  curl --fail --silent --show-error \
    "https://api.matrices.com/v1/agents/$AGENT_ID/threads" \
    -H "Authorization: Bearer $MATRICES_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "content": [
        { "type": "text", "text": "Send a POST to https://httpbin.org/post with a small JSON body, then tell me what came back." }
      ]
    }' | jq -er '.threadId'
)"
```

## 3. Find what's waiting

One filter on the thread list gives you everything waiting on a person. The turn
runs in the background, so poll until yours shows up:

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
until WAIT_ID="$(
  curl --fail --silent --show-error -H "Authorization: Bearer $MATRICES_API_KEY" \
    "https://api.matrices.com/v1/threads?state=waiting&limit=50" |
    jq -er --arg t "$THREAD_ID" '.threads[] | select(.thread.id == $t) | .pendingWaits[0].id'
)"; do sleep 2; done
export WAIT_ID
```

`pendingWaits` is the list of calls stopped and waiting for an answer. Each
entry carries the `hookId` that fired, the `toolName` and `toolInput` about to
run, and an `expiresAt`. That's your approval queue; show it to whoever
decides.

## 4. Decide

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl --fail --silent --show-error -X POST \
  "https://api.matrices.com/v1/threads/$THREAD_ID/waits/$WAIT_ID/resolve" \
  -H "Authorization: Bearer $MATRICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "allow", "reason": "Amount matches the invoice." }'
```

`allow` releases the call and the turn picks up where it stopped. `deny` drops
it and the model is told why, so give a reason worth reading.

## Check it worked

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl --fail --silent --show-error -H "Authorization: Bearer $MATRICES_API_KEY" \
  "https://api.matrices.com/v1/threads/$THREAD_ID/events?limit=100" |
  jq -r '.events[]
    | select(.type == "hook_decision" or .type == "wait_created" or .type == "wait_resolved")
    | "\(.seq) \(.type) \(.hook.decision // .waitResult.decision // "")"'
```

You should see three lines, in order. `hook_decision ask` is the hook deciding
to ask you instead of answering for you. `wait_created` is the turn stopping.
`wait_resolved` is your answer; its `waitResult` holds the `decision` and the
`reason` you sent. After that, the `tool_result` shows the call finally running.
Deny instead and that `tool_result` carries `failureCode: hook_denied` with your
reason as `failureText`. See [Thread events](/reference/thread-events).

## When it goes sideways

* **Nobody answers.** The wait expires after `asyncTimeoutMs` (24 hours for a
  `client` hook, which is also the maximum) and the call is dropped with
  `wait_expired`. Under `fail_closed`, silence counts as a no.
* **Two people answer.** The second resolve gets `409 Conflict`. A wait settles
  once.
* **You edit the profile while a call is waiting.** Nothing changes. The turn is
  running against the snapshot it started with, so your edit applies to the next
  turn, not this one.

## Where next

<CardGroup cols={2}>
  <Card title="Hooks reference" icon="book" href="/reference/hooks">
    Points, processors, decisions, and the defaults.
  </Card>

  <Card title="Run on a schedule" icon="clock" href="/guides/run-on-a-schedule">
    Same agent, no one at the keyboard.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.