Make Your First Agent
A full walkthrough of defining typed tools with Zod, constructing an Agent, running it with run(), and upgrading to real-time streaming with runStream().
Defining a Tool
Tools are the primary way agents interact with the outside world — APIs, databases, file systems, anything. Use defineTool() to declare a tool with a name, a description (used by the model to decide when to call it), a Zod parameter schema, and anexecute function.
import "dotenv/config";import { defineTool } from "@mehularora/stark-kit";import z from "zod";
const searchTool = defineTool({ name: "searchWeb", description: "Search the web for information on a given topic.", parameters: z.object({ query: z.string().describe("The search query string"), maxResults: z.number().int().min(1).max(10).optional(), }), execute: async ({ query, maxResults = 5 }) => { // Your actual implementation here return { results: [`Result for "${query}" (top ${maxResults})`] }; },});Returning structured errors with ToolResult
Instead of throwing errors (which would crash the run loop), you can return a ToolResult with success: false. The model receives the error message and can attempt to self-correct — for example, by rewriting a broken SQL query.
import { defineTool, ToolResult } from "@mehularora/stark-kit";import z from "zod";
const dbQueryTool = defineTool({ name: "queryDatabase", description: "Execute read-only SQL queries.", parameters: z.object({ sql: z.string() }), execute: async ({ sql }): Promise<ToolResult> => { try { const data = await db.query(sql); return { success: true, data }; } catch (err) { // The model receives this and can self-correct its query return { success: false, error: err instanceof Error ? err.message : String(err), }; } },});Building an Agent
Pass your tools and a provider instance to new Agent(). The instructions field becomes the system prompt — be specific about when tools should be used.
import "dotenv/config";import { Agent, OpenAIProvider, defineTool } from "@mehularora/stark-kit";import z from "zod";
const provider = new OpenAIProvider({ model: "gpt-4o" });
const searchTool = defineTool({ name: "searchWeb", description: "Search the web for a query.", parameters: z.object({ query: z.string() }), execute: async ({ query }) => `Results for "${query}": ...`,});
const agent = new Agent({ name: "ResearchAssistant", provider, instructions: "You are a research assistant. Always search before answering factual questions.", tools: [searchTool], maxSteps: 15, // stop after 15 steps (default: 10) temperature: 0.3, // lower = more deterministic model: "gpt-4o-mini", // overrides provider default for this agent});namestringRequired. Identifier for this agent (shown in handoff events).
instructionsstringRequired. The system prompt given to the model on every step.
providerProviderRequired. An OpenAIProvider, ClaudeProvider, GeminiProvider, or MistralProvider instance.
toolsIToolOptions[]Optional. Array of tools defined with defineTool().
maxStepsnumberOptional (default: 10). Maximum number of run loop iterations.
temperaturenumberOptional. Sampling temperature passed to the provider.
modelstringOptional. Model name that overrides the provider's default.
hooksAgentHooksOptional. beforeChat, beforeTool, afterTool lifecycle callbacks.
outputTypez.ZodTypeOptional. A Zod schema that forces structured JSON output.
Running the Agent
Call run() with the agent and a user message. The message can be a plain string or a CanonicalMessage[] array for multi-turn conversations.
import { run, isHITLPause } from "@mehularora/stark-kit";
const result = await run({ agent, messages: "What are the latest advancements in quantum computing?", maxSteps: 10, // optional override temperature: 0.5, // optional override});
// result is either a RunResult or a HITLPauseif (isHITLPause(result)) { // A tool with requiresApproval: true was triggered — see HITL docs console.log("Waiting for approval:", result.pendingToolCalls);} else if (result.status === "complete") { console.log("Final answer:", result.content); // If agent used outputType (structured output): // console.log("Structured data:", result.finalOutput);}run() returns either a RunResult (when the loop completes normally) or a HITLPause (when a tool with requiresApproval: true was triggered). Always check with isHITLPause(result) before reading the result.Streaming with runStream()
Use runStream() instead of run() to receive events as they happen. This is useful for streaming text to the user in real-time, showing tool call progress, or building reactive UIs.
import "dotenv/config";import { runStream } from "@mehularora/stark-kit";
const stream = runStream({ agent, messages: "Tell me about quantum computing and then search for recent news.",});
for await (const event of stream) { switch (event.type) { case "chunk": // Text delta from the model — write to terminal or UI if (event.chunk.type === "text") { process.stdout.write(event.chunk.delta); } break;
case "tool_start": console.log(`\n[→ Calling: ${event.toolName}]`); break;
case "tool_end": console.log(`[← Finished: ${event.toolName}]`); break;
case "handoff": // Only emitted during multi-agent handoffs console.log(`[Handing off to: ${event.targetAgentName}]`); break;
case "step_complete": // One full step of the loop completed break;
case "hitl_pause": // A tool with requiresApproval triggered mid-stream console.log("Paused for approval:", event.result.pendingToolCalls); break;
case "done": // Stream finished — event.result is the final RunResult console.log("\n\nFinal answer:", event.result.content); break; }}The generator yields RunStreamEvent objects. The possible event types are: chunk, tool_start, tool_end, handoff, step_complete, hitl_pause, and done.
done event is always emitted last. Its event.result is the complete RunResult — the same object you would get from a non-streaming run() call.