Orchestration
Build multi-agent networks with createHandoffTool, add guardrails with lifecycle hooks, and force structured JSON responses with outputType.
Agent Handoffs
Use createHandoffTool(targetAgent) to give an agent the ability to transfer execution to another agent. When the triage agent calls the handoff tool, the run loop transparently switches to the target agent and continues from there. The original agent never sees the target agent's response — the loop just picks up as if you had called run() on the target agent directly.
import "dotenv/config";import { Agent, run, createHandoffTool, MistralProvider } from "@mehularora/stark-kit";
const provider = new MistralProvider();
// Specialised downstream agentsconst billingAgent = new Agent({ name: "BillingAgent", provider, instructions: "You handle billing questions, refunds, and invoice disputes. Be concise and accurate.",});
const supportAgent = new Agent({ name: "SupportAgent", provider, instructions: "You handle technical issues and product bugs. Ask for reproduction steps.",});
// The triage agent routes to the right specialistconst triageAgent = new Agent({ name: "TriageAgent", provider, instructions: "Determine the customer intent and hand off to the correct specialist immediately. Do not answer yourself.", tools: [ createHandoffTool(billingAgent), createHandoffTool(supportAgent), ],});
const response = await run({ agent: triageAgent, messages: "I need a refund for order #4821.",});
if (response.status === "complete") { console.log("Handled by:", response.agent.name); // "BillingAgent" console.log("Answer:", response.content);}Reading the result after a handoff
After the loop completes, response.agent tells you which agent produced the final answer. This is useful for logging, analytics, or rendering the response with the right agent branding in a chat UI.
// After run() completes following a handoff:console.log(response.agent.name); // The agent that produced the final answerconsole.log(response.content); // The final answer textmaxSteps budget.Lifecycle Hooks
The hooks option on Agent lets you inject behavior at three key points in the run loop without modifying the tool or provider code. This is useful for guardrails, logging, secrets redaction, and policy enforcement.
import "dotenv/config";import { Agent, ClaudeProvider } from "@mehularora/stark-kit";
const provider = new ClaudeProvider();
const agent = new Agent({ name: "SecureAgent", provider, instructions: "Handle user requests.", hooks: { // Runs before every LLM call — return a modified history or nothing beforeChat: async (history) => { return history.map(msg => ({ ...msg, // Redact API keys from user messages before sending to the LLM content: typeof msg.content === "string" ? msg.content.replace(/api_key=[\w-]+/gi, "api_key=REDACTED") : msg.content, })); },
// Runs before each tool execution — throw to block, return new args to override beforeTool: async (toolName, args) => { if (toolName === "deleteFiles" && (args as any).path.startsWith("/etc")) { throw new Error("Permission denied: cannot delete system files."); } // Returning undefined (or nothing) allows the call to proceed unchanged },
// Runs after each tool execution — return a modified result string or nothing afterTool: async (toolName, result, isError) => { // Mask Social Security numbers in tool responses before the LLM sees them return result.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "***-**-****"); }, },});Hook signatures
// AgentHooks interface referenceinterface AgentHooks { // Called with the full message history before every LLM step. // Return a CanonicalMessage[] to replace the history, or undefined to keep it. beforeChat?(history: CanonicalMessage[]): Promise<CanonicalMessage[] | void>;
// Called with the tool name and parsed arguments before execution. // Throw to block the call. Return new args to override them. beforeTool?(toolName: string, args: unknown): Promise<unknown | void>;
// Called with the tool name, result string, and error flag after execution. // Return a new string to replace the result the LLM will see. afterTool?(toolName: string, result: string, isError: boolean): Promise<string | void>;}beforeChatReceives the full message history. Return a modified history to replace it, or return nothing to leave it unchanged. Good for redacting sensitive data before it reaches the LLM.
beforeToolReceives the tool name and parsed arguments. Throw an Error to block execution entirely. Return new arguments to override what the model chose. Return nothing to proceed as-is.
afterToolReceives the tool name, its string result, and a boolean indicating if it errored. Return a new string to replace the result the LLM sees. Useful for masking PII or normalizing output.
Structured Outputs
Pass a Zod schema to the outputType option and Stark-Kit injects the schema contract into the system prompt and auto-configures a submit_final_output tool. The model must call that tool to finish — plain text responses are rejected and retried automatically.
import "dotenv/config";import { Agent, run, OpenAIProvider } from "@mehularora/stark-kit";import z from "zod";
const provider = new OpenAIProvider();
// Define the exact shape of the response you wantconst FeedbackSchema = z.object({ sentiment: z.enum(["positive", "negative", "neutral"]), topics: z.array(z.string()).describe("Main topics mentioned in the feedback"), score: z.number().min(0).max(10).describe("Sentiment intensity from 0 to 10"),});
const analyzer = new Agent({ name: "FeedbackAnalyzer", provider, instructions: "Analyze customer feedback. Extract sentiment, topics, and a severity score.", outputType: FeedbackSchema, // ← bind the output schema here});
const result = await run({ agent: analyzer, messages: "The checkout was confusing and the email confirmation never arrived. Very frustrating.",});
if (result.status === "complete") { // result.finalOutput is typed as z.infer<typeof FeedbackSchema> console.log(result.finalOutput); // → { sentiment: "negative", topics: ["checkout", "email confirmation"], score: 8 }}Accessing the typed output
When an agent has outputType set, the final result has a finalOutput field typed as z.infer<typeof YourSchema>. The regular result.content is still populated with the JSON string for convenience.
submit_final_output, try increasing maxSteps or switching to a more capable model.