A gate before a tool
A gate is a function Cortex calls before every tool call. It lets the call through, refuses it, or stops the run until a person answers.
pnpm add @niscorp/cortex @niscorp/signal zod openaiThe model
createSignal makes the client that calls the model. Cortex is handed this client, here for Groq, and Signal reads the key from GROQ_API_KEY.
import { z } from 'zod';
import { createSignal } from '@niscorp/signal';
import { defineAgent, defineTool } from '@niscorp/cortex';
import type { ToolGate } from '@niscorp/cortex';
const llm = createSignal('groq');A tool that moves money
The function gives a refund. Nothing in the tool says who may call it: that is the gate’s.
const refund = defineTool({
id: 'refund',
name: 'refund',
description: 'Refund a booking to the card it was paid with',
input: z.object({ booking: z.string(), amount_cents: z.number() }),
execute: ({ booking, amount_cents }) => ({ booking, refunded_cents: amount_cents }),
});The gate
A gate is given the call, by the tool’s id and its arguments, and answers one of three things.
deny refuses: the function does not run, the model is given the reason as the result of its call, and the run goes on. ask stops the run for a person. allow lets it through, and may hand back changed arguments.
const LIMIT_CENTS = 10_000;
const gate: ToolGate = (call) => {
if (call.toolId !== 'refund') return { allow: true };
const asked = z.object({ amount_cents: z.number() }).safeParse(call.args);
if (asked.success && asked.data.amount_cents > LIMIT_CENTS) {
return { deny: 'A refund over 100 euros is made at the desk.' };
}
return { ask: { reason: 'A refund moves money, so a person says yes or no.' } };
};The agent
The gate is listed on the agent. It is asked before every call to any of its tools.
const desk = defineAgent({
id: 'desk',
instructions: 'Refund a booking when the customer asks for it.',
tools: [refund],
toolGates: [gate],
});A person answers
When a gate asks, the run sends an approval-required event and waits. run.approve lets the call through, and run.deny refuses it with a reason the model is told.
Here the answer is given at once. In an app it is a dialog: the event carries the tool, its arguments and the gate’s reason, which is everything a person needs to decide.
const run = desk.run('Please refund booking B-2041, 58 euros.', { llm });
run.onEvent((event) => {
if (event.type === 'approval-required') {
console.log('asked:', event.approval.toolId, event.approval.args, event.approval.reason);
run.approve(event.approval.id);
}
if (event.type === 'tool-end') console.log('tool ended:', event.observation.kind);
});
const result = await run.result;
console.log(result.ok ? result.output.response : result.error.message);Run it.
This file asks a model, so it is not run on this site. When the model asks for the refund, it prints the refund and the gate’s reason, then tool ended: result, and the model’s sentence saying the refund was made. Change the question to a refund over 100 euros and it prints tool ended: denied, and the model’s answer gives the gate’s reason.
GROQ_API_KEY=your-key npx tsx gate.mts