Skip to content

Run an agent

In this tutorial you will drive one turn of the agent runtime from scratch. You will create an OpenAI client, define a couple of tools the model may call, build the conversation as a Prompt, construct an AgentRun, stream the reply as it arrives, and read the finished transcript.

By the end you will have a working script that asks the model a question, watches it call a tool, and prints its answer.

You need an OpenAI API key and the two runtime packages installed:

Terminal window
npm install @grundlag/agent @grundlag/core openai

Export your key so the OpenAI client can pick it up:

Terminal window
export OPENAI_API_KEY=sk-...

The agent runtime does not wrap OpenAI — it uses a client you hand it. Create one directly:

import OpenAI from 'openai';
const client = new OpenAI();

The model calls tools — transport-neutral callables with typed input and output. Define two small ones with defineTool. A tool’s execute takes the input directly (its second argument is a context object, unused here):

import { defineTool, z } from '@grundlag/core';
const getTime = defineTool({
id: 'getTime',
name: 'Get the current time',
description: 'Returns the current time as an ISO 8601 string.',
input: z.object({}),
output: z.object({ now: z.string() }),
execute: async () => ({ now: new Date().toISOString() }),
});
const daysUntil = defineTool({
id: 'daysUntil',
name: 'Days until a date',
description: 'Returns the whole number of days between now and a target date.',
input: z.object({
target: z.string().describe('The target date as an ISO 8601 string.'),
}),
output: z.object({ days: z.number().int() }),
execute: async (input) => {
const ms = new Date(input.target).getTime() - Date.now();
return { days: Math.floor(ms / 86_400_000) };
},
});

Each tool’s input schema becomes the model’s parameter schema, and its description becomes the tool description the model reads. We define tools directly here; in a real app they usually come from a host’s providers via providers.toTools(). See Tools and transports.

A run operates over a list of Prompt objects — the conversation history. The last prompt in the list carries the new user message; everything before it is prior context. For a first turn there is just one prompt:

import type { Prompt } from '@grundlag/core';
const prompt: Prompt = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
content: [{ type: 'text', text: 'How many days until 2027-01-01?' }],
output: [],
};

The output array starts empty. The run fills it in as the model produces text, calls tools, and records their results.

AgentRun ties the pieces together. The actions map keys are the tool names the model sees, so give them names the model can reason about:

import { AgentRun } from '@grundlag/agent';
const run = new AgentRun({
systemPrompt: 'You are a concise assistant. Use the tools when they help.',
client,
model: 'gpt-4o',
prompts: [prompt],
actions: {
getTime,
daysUntil,
},
});

The actions map is a Record<string, Tool>; its keys are the tool names the model sees. Each tool already carries whatever it depends on, so the run needs no service container.

The run is an event emitter. Subscribe to output:delta to receive text as the model produces it, chunk by chunk:

run.on('output:delta', ({ delta }) => {
process.stdout.write(delta);
});

Call run(). It resolves once the model stops requesting tools:

const result = await run.run();
console.log('\n\nstatus:', result.status);
console.log('outputs:', result.prompt.output.length);

Run the script. You will see the answer stream in, then a summary like:

It's 164 days until 2027-01-01.
status: completed
outputs: 2

Under the hood the run did this:

  1. It converted getTime and daysUntil into OpenAI function tools from their input schemas.
  2. It sent your system prompt and the conversation to the model as a streaming completion.
  3. The model chose to call daysUntil with a target. The run executed the matching action, recorded the result on the prompt, and looped.
  4. With the tool result in hand, the model produced its final text answer — which streamed to your listener as output:delta events.
  5. With no more tool calls requested, the run emitted completed and resolved with status: 'completed'.

Everything that happened is captured in result.prompt.output: the tool call (with its input and output) and the final text. Because the whole conversation lives in the Prompt, you can persist it and reconstruct the run later.