Skip to content
Warlock.js v4

Your first agent

Five minutes from zero to a streaming agent. By the end you’ll have run a real OpenAI request, seen the typed result envelope, and streamed tokens to stdout.

Terminal window
yarn add @warlock.js/ai @warlock.js/ai-openai

Set your API key in .env:

Terminal window
OPENAI_API_KEY=sk-...
import { ai } from "@warlock.js/ai";
import { OpenAISDK } from "@warlock.js/ai-openai";
const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const myAgent = ai.agent({
model: openai.model({ name: "gpt-4o-mini" }),
});
const result = await myAgent.execute("Say hi in one short sentence.");
console.log(result.text);
console.log(result.usage.total, "tokens");

Run it:

Terminal window
tsx --env-file=.env first-agent.ts

You should see a one-line greeting plus the token count.

gpt-4o-mini is the cheapest reasonable default. Swap to gpt-4o or any other model name on the same openai.model({...}) call.

The system prompt is what shapes the agent’s behavior. It can be a plain string, or a composable SystemPrompt built from ai.persona() / ai.instruction() blocks.

const myAgent = ai.agent({
model: openai.model({ name: "gpt-4o-mini" }),
systemPrompt: ai.systemPrompt()
.persona("You are Alex, a senior TypeScript engineer.")
.instruction("Always cite the relevant TypeScript handbook section.")
.instruction("Reply in plain prose — no markdown headings."),
});

The builder is immutable — each call returns a new prompt — so you can fork a base prompt into variants safely. See Write system prompts for the full surface.

Replace execute with stream and iterate. Each event carries a delta you can write straight to the terminal:

const stream = myAgent.stream("Explain generics to a Go developer.");
for await (const event of stream) {
if (event.type === "agent.trip.streaming") {
process.stdout.write(event.delta);
}
}
const result = await stream.result;
console.log("\n\nTotal:", result.usage.total, "tokens");

stream returns an async iterable plus a .result promise. Iterate for the live UI, await for the final envelope.

execute() never throws. Every failure surfaces as result.error:

const { data, text, error } = await myAgent.execute("");
if (error) {
console.error(error.code, error.category, error.message);
return;
}
console.log(text);

You can branch on instanceof ProviderRateLimitError, on error.code === "PROVIDER_RATE_LIMIT", or on the coarser error.category for dashboards. See Handle errors.

Tools turn the agent into something more useful than a thin chat wrapper. Each tool is a typed async function the model can call by name.

import { v } from "@warlock.js/seal";
const getTimeTool = ai.tool({
name: "get_time",
description: "Return the current ISO timestamp for a given timezone.",
input: v.object({ timezone: v.string() }),
execute: async ({ timezone }) => {
return { time: new Date().toLocaleString("en-US", { timeZone: timezone }) };
},
});
const myAgent = ai.agent({
model: openai.model({ name: "gpt-4o-mini" }),
tools: [getTimeTool],
});
const { text } = await myAgent.execute("What time is it in Cairo?");

The agent dispatches the tool, feeds the result back into a second trip, and the model produces the natural-language reply. See Define tools.

When you want a typed object back instead of free text, declare an output schema:

import { v, type Infer } from "@warlock.js/seal";
const summarySchema = v.object({
title: v.string(),
bullets: v.array(v.string()).min(1),
});
type Summary = Infer<typeof summarySchema>;
const summarizer = ai.agent({
model: openai.model({ name: "gpt-4o-mini" }),
output: summarySchema,
});
const { data } = await summarizer.execute(longArticleText);
if (data) {
// typed as Summary
console.log(data.title);
data.bullets.forEach((b) => console.log("-", b));
}

Adapters with native structuredOutput forward the schema to the provider; others get a soft “respond in JSON only” instruction. Client-side validation always runs, so data is type-safe either way.