Your own loop
One call to the model. Give it a schema and it says whether the turn was tool calls, an answer or a failure. It is what Cortex is built on.
pnpm add @niscorp/signal zod openaiThe client
createSignal names a provider and gives you a client. The key is read from GROQ_API_KEY when a call is made, and the model is the provider’s default unless you name one with .model().
import { z } from 'zod';
import { createSignal } from '@niscorp/signal';
import type { StepInputMessage } from '@niscorp/signal';
const signal = createSignal('groq');What the model is given
With step nothing is kept for you and nothing is added. The messages are yours, the instruction to answer in JSON included. A tool is only its description: a name, a sentence and a JSON Schema for its arguments.
Answer is what you will accept as the end.
const Answer = z.object({ left: z.number() });
const messages: StepInputMessage[] = [
{ role: 'system', content: 'Look the night up, then reply with only JSON: { "left": number }.' },
{ role: 'user', content: 'How many seats are left on 2026-10-24?' },
];
const tools = [
{
name: 'seatsLeft',
description: 'How many seats are left on a night',
parameters: z.toJSONSchema(z.object({ night: z.string() })),
},
];One step and its outcome
A step sends the messages and returns. Because it was given a schema to accept, outcome says which of three things the model’s turn was: tool_calls, an output that passes the schema, or failed.
wire.rung says how the output was read: parse as it stood, extract out of the words around it. The loop opened here closes in the last block.
for (let turn = 0; turn < 5; turn += 1) {
const step = await signal.step({ messages, tools, output: { accept: Answer } });
const outcome = step.outcome;
if (outcome?.kind === 'output') {
console.log(Answer.parse(outcome.value), '· read by:', step.wire?.rung);
break;
}Tool calls
Signal runs no tool here. The model’s turn goes into the messages, then the result of each call. The tool is two lines of your own code.
if (outcome?.kind === 'tool_calls') {
messages.push({
role: 'assistant',
content: step.content,
toolCalls: outcome.calls.map((call) => ({ id: call.id, name: call.name, args: JSON.stringify(call.args) })),
});
for (const call of outcome.calls) {
const { night } = z.object({ night: z.string() }).parse(call.args);
const result = { night, left: night === '2026-10-24' ? 113 : 120 };
messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: JSON.stringify(result) });
}
continue;
}An answer that failed
A failed turn comes with evidence: what the JSON parser or your schema said was wrong. Signal retries nothing here. Sending the evidence back is one more message of yours.
messages.push({ role: 'system', content: `That was not the answer: ${outcome?.evidence ?? 'nothing came back'}` });
}Run it.
This file asks a model, so it is not run on this site. When the model calls the tool and then answers, it prints { left: 113 } · read by: parse. When the answer comes wrapped in a sentence or a code fence, it ends in extract.
GROQ_API_KEY=your-key npx tsx loop.mts