novo agents

Build a tool approval hook

A tool approval hook gates dangerous tools behind a check you control. Novo POSTs to your URL right before the tool fires; you return allow, deny, or pause.

1. Set up the endpoint

// app/api/novo/approvals/route.ts
import { verifyNovoSignature } from 'novoagents';

export async function POST(req: Request) {
  const raw = await req.text();
  const result = verifyNovoSignature({
    rawBody: raw,
    signatureHeader: req.headers.get('Novo-Signature')!,
    secret: process.env.NOVO_APPROVAL_SECRET!,
  });
  if (!result.valid) return new Response('bad signature', { status: 401 });

  const body = JSON.parse(raw) as {
    toolName: string;
    input: { command?: string } & Record<string, unknown>;
  };

  // toolName is Novo's canonical public name, not a provider-native name.
  // Returning `allow` auto-accepts the call: it fires with no pause.
  if (body.toolName === 'read_file') {
    return Response.json({ decision: 'allow' });
  }

  // Block known-dangerous shapes. The reason is shown to the model verbatim.
  if (body.toolName === 'bash' && /(rm -rf|sudo|curl.*\|.*sh)/.test(body.input.command ?? '')) {
    return Response.json({ decision: 'deny', reason: 'destructive command' });
  }

  // Everything else: pause and route to a human.
  return Response.json({ decision: 'pause' });
}

For tools that are always safe to run, you don't even need a hook branch: list their canonical names in approvals.autoAllow on the agent and Novo skips the hook for them entirely:

await novo.agents.update(agent.id, {
  approvals: {
    toolExecution: { hookId: hook.id },
    autoAllow: ['read_file', 'list_directory'],
  },
});

2. Register the hook

const hook = await novo.toolApprovalHooks.register({
  name: 'shell-gate',
  url: 'https://api.acme.dev/novo/approvals',
  secret: process.env.NOVO_APPROVAL_SECRET!,
  onHookError: 'pause',
});

3. Attach to an agent

await novo.agents.update(agent.id, {
  approvals: { toolExecution: { hookId: hook.id } },
});

Every tool call on the agent now goes through your hook. The hook's per-call response decides whether each tool fires, gets denied, or pauses for human resolution.

4. Handle the pause

When your hook returns pause, the run pauses with status: 'waiting_for_approval' and a data-novo-input-requested chunk fires on the stream with request.kind: 'approval_request'. Resolve from your code by selecting the approve or deny option:

for await (const event of stream) {
  if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'approval_request') {
    const { id, toolName, request } = event.data;
    const ok = await yourTeam.review({ toolName, input: request.input, preview: request.preview });
    await novo.interactions.resolve(id, {
      inputResponses: [{ requestId: id, optionId: ok ? 'approve' : 'deny' }],
      reason: ok ? 'approved by on-call' : 'blocked',
    });
  }
}

A denial returns a tool-output-denied chunk for the call. The reason you pass rides along verbatim; Novo does not tag it with who denied (human vs. hook).

You can also resolve from a different process: Slack interaction, web dashboard, email link. The run waits.

5. Test before going live

const { decision, reason, latencyMs } = await novo.toolApprovalHooks.test(hook.id);

Sends a synthetic approval request to your URL. Confirm decision + signature verification + latency before flipping the agent on.

onHookError policy

Decides what happens when your hook URL itself fails (timeout, 5xx, unreachable host, or a 200 whose body is not a valid decision):

| onHookError | Behavior | |---|---| | deny | Block the tool call without pausing. The model sees tool-output-denied and decides what to do next. This is the default when you omit onHookError. | | pause | Park the run at waiting_for_approval so a human can resolve it. Recommended when you have a human escalation path. | | fail-run | Terminate the run with status: 'failed'. Use when a hook failure means you cannot safely continue. |

Failure and retry semantics

Novo delivers each review request exactly once. There is no automatic retry or redelivery; on any failure, onHookError is applied immediately:

  • One delivery, bounded by requestTimeoutMs. The POST is given requestTimeoutMs (default and hard ceiling 790 seconds). A timeout, a non-2xx status, an unreachable host, a refused redirect, or a 200 with an unparseable/invalid body each resolve straight to your onHookError policy. Novo does not re-POST after a transient 5xx.
  • pause parks; it does not redeliver. Whether pause comes from your hook (returning { "decision": "pause" }) or from onHookError: 'pause', the run parks at waiting_for_approval and waits for an external decision via POST /v1/interactions/{id}/resolve (the resolveUrl is included in every request). The hook is never called again for that tool call.
  • Need to stall a call? Return { "decision": "pause" } with a 200; that is the supported way to defer a decision you cannot make synchronously. Do not rely on returning a 5xx to "buy time"; it will not be retried, and under the default policy it denies.

Idempotency: if the same review is presented again (durable replay), Novo returns the already-recorded decision instead of calling your hook a second time.

Worker (subagent) tool calls

When the tool call comes from a worker (a delegated subagent), the request and the resulting interaction carry the worker's lineage so you can map the call back to its root run without tracking it yourself:

  • runId is the worker's own run id (the run that issued the tool call).
  • rootRunId is the tree root, always present; for a root-run call it equals runId.
  • workerRef is the worker's address (wkr_…), present only for worker calls.
const body = JSON.parse(raw) as {
  runId: string;
  rootRunId: string;
  workerRef?: string;
  toolName: string;
  // …
};
// Address the worker's sub-stream directly:
if (body.workerRef) {
  const workerStream = await novo.runs.workers.stream(body.rootRunId, body.workerRef);
}

The same rootRunId/workerRef appear on the data-novo-input-requested chunk and on the Interaction object from interactions.get. For Novo-hosted MCP / HTTP-connection tool calls, the equivalent values arrive as the Novo-Root-Run-Id and Novo-Worker-Ref request headers (alongside Novo-Run-Id).

What to log

For every hook POST: timestamp, run ID, tool name, args, decision, your reason. This is the audit trail you'll wish you had the first time something goes wrong.

See also