Signal · guide

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.

Install it
pnpm add @niscorp/signal zod openai
Step 1 of 5

The 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().

The client
import { z } from 'zod';
import { createSignal } from '@niscorp/signal';
import type { StepInputMessage } from '@niscorp/signal';

const signal = createSignal('groq');
Step 2 of 5

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.

What the model is given
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() })),
  },
];
Step 3 of 5

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.

One step and its outcome
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;
  }
Step 4 of 5

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.

Tool calls
  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;
  }
Step 5 of 5

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.

An answer that failed
  messages.push({ role: 'system', content: `That was not the answer: ${outcome?.evidence ?? 'nothing came back'}` });
}
Then

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.

Run it
GROQ_API_KEY=your-key npx tsx loop.mts