Creating Agents — Agentuity Documentation

Creating Agents

Build agents with createAgent(), schemas, and handlers

Each agent encapsulates a handler function, input/output validation, and metadata in a single unit that can be invoked from routes, other agents, or scheduled tasks.

Basic Agent

Create an agent with createAgent(), providing a name and handler function:

import { createAgent } from '@agentuity/runtime';
 
const agent = createAgent('Greeter', {
  handler: async (ctx) => {
    ctx.logger.info('Processing request');
    return { message: 'Hello from agent!' };
  },
});
 
export default agent;

For the complete createAgent() signature and all configuration options, see the Agents Reference.

The handler always receives ctx, the agent context with logging, storage, and state management. When you define schema.input, the handler receives validated input as its second parameter.

Adding LLM Capabilities

Most agents use an LLM for inference. Here's an agent using the AI SDK:

import { createAgent } from '@agentuity/runtime';
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { s } from '@agentuity/schema';
 
const agent = createAgent('Assistant', {
  schema: {
    input: s.object({ prompt: s.string() }),
    output: s.object({ response: s.string() }),
  },
  handler: async (ctx, { prompt }) => {
    const { text } = await generateText({
      model: openai('gpt-5-mini'),
      prompt,
    });
 
    return { response: text };
  },
});
 
export default agent;

You can also use provider SDKs directly:

import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
import OpenAI from 'openai';
 
const client = new OpenAI();
 
const agent = createAgent('Assistant', {
  description: 'An agent using OpenAI SDK directly',
  schema: {
    input: s.object({ prompt: s.string() }),
    output: s.string(),
  },
  handler: async (ctx, { prompt }) => {
    const completion = await client.chat.completions.create({
      model: 'gpt-5-mini',
      messages: [{ role: 'user', content: prompt }],
    });
 
    return completion.choices[0]?.message?.content ?? '';
  },
});
 
export default agent;

Or use Groq for fast inference with open-source models:

import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
import Groq from 'groq-sdk';
 
const client = new Groq();
 
const agent = createAgent('Assistant', {
  description: 'An agent using Groq SDK with open-source models',
  schema: {
    input: s.object({ prompt: s.string() }),
    output: s.string(),
  },
  handler: async (ctx, { prompt }) => {
    const completion = await client.chat.completions.create({
      model: 'llama-3.3-70b-versatile',
      messages: [{ role: 'user', content: prompt }],
    });
 
    return completion.choices[0]?.message?.content ?? '';
  },
});
 
export default agent;

For streaming, structured output, and more patterns, see Using the AI SDK.

Adding Schema Validation

Define input and output schemas for type safety and runtime validation. Agentuity includes a lightweight built-in schema library:

import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
 
const agent = createAgent('Contact Form', {
  schema: {
    input: s.object({
      email: s.string(),
      message: s.string(),
    }),
    output: s.object({
      success: s.boolean(),
      id: s.string(),
    }),
  },
  handler: async (ctx, input) => {
    // input is typed as { email: string, message: string }
    ctx.logger.info('Received message', { from: input.email });
 
    return {
      success: true,
      id: crypto.randomUUID(),
    };
  },
});
 
export default agent;

You can also use Zod for more advanced validation:

import { createAgent } from '@agentuity/runtime';
import { z } from 'zod';
 
const agent = createAgent('Contact Form', {
  schema: {
    input: z.object({
      email: z.string().email(),
      message: z.string().min(1),
    }),
    output: z.object({
      success: z.boolean(),
      id: z.string(),
    }),
  },
  handler: async (ctx, input) => {
    ctx.logger.info('Received message', { from: input.email });
 
    return {
      success: true,
      id: crypto.randomUUID(),
    };
  },
});
 
export default agent;

Validation behavior:

  • Input is validated before the handler runs
  • Output is validated before returning to the caller
  • Invalid data throws an error with details about what failed

Type Inference

TypeScript automatically infers types from your schemas. Don't add explicit type annotations to handler parameters:

// Good: types inferred from schema
handler: async (ctx, input) => { ... }
 
// Bad: explicit types can cause issues
handler: async (ctx: AgentContext, input: MyInput) => { ... }
const agent = createAgent('Search', {
  schema: {
    input: z.object({
      query: z.string(),
      filters: z.object({
        category: z.enum(['tech', 'business', 'sports']),
        limit: z.number().default(10),
      }),
    }),
    output: z.object({
      results: z.array(z.string()),
      total: z.number(),
    }),
  },
  handler: async (ctx, input) => {
    // Full autocomplete for input.query and input.filters.category
    const category = input.filters.category; // type: 'tech' | 'business' | 'sports'
 
    return {
      results: ['result1', 'result2'],
      total: 2,
    };
  },
});

Common Zod Patterns

z.object({
  // Strings
  name: z.string().min(1).max(100),
  email: z.string().email(),
  url: z.string().url().optional(),
 
  // Numbers
  age: z.number().min(0).max(120),
  score: z.number().min(0).max(1),
 
  // Enums and literals
  status: z.enum(['active', 'pending', 'complete']),
  type: z.literal('user'),
 
  // Arrays and nested objects
  tags: z.array(z.string()),
  metadata: z.object({
    createdAt: z.date(),
    version: z.number(),
  }).optional(),
 
  // Defaults
  limit: z.number().default(10),
})

Schema Descriptions for AI

When using generateObject() from the AI SDK, add .describe() to help the LLM understand each field:

z.object({
  title: z.string().describe('Event title, concise, without names'),
  startTime: z.string().describe('Start time in HH:MM format (e.g., 14:00)'),
  priority: z.enum(['low', 'medium', 'high']).describe('Urgency level'),
})

Call .describe() at the end of the chain: schema methods like .min() return new instances that don't inherit metadata.

Handler Context

The handler context (ctx) provides access to Agentuity services:

handler: async (ctx, input) => {
  // Logging (Remember: always use ctx.logger, not console.log)
  ctx.logger.info('Processing', { data: input });
  ctx.logger.error('Something failed', { error });
 
  // Identifiers
  ctx.sessionId;           // Unique per request (sess_...)
  ctx.thread.id;           // Conversation context (thrd_...)
  ctx.current.name;        // This agent's name
  ctx.current.agentId;     // Stable ID for namespacing state keys
 
  // State management
  ctx.state.set('key', value);                   // Request-scoped (sync, cleared after response)
  await ctx.thread.state.set('key', value);      // Thread-scoped (async, up to 1 hour)
  ctx.session.state.set('key', value);           // Session-scoped
 
  // Storage
  await ctx.kv.set('bucket', 'key', data);
  await ctx.vector.search('namespace', { query: 'text' });
 
  // Background tasks
  ctx.waitUntil(async () => {
    await ctx.kv.set('analytics', 'event', { timestamp: Date.now() });
  });
 
  return { result };
}

For detailed state management patterns, see Managing State.

Agent Name and Description

Every agent requires a name (first argument) and can include an optional description:

const agent = createAgent('Support Ticket Analyzer', {
  description: 'Analyzes support tickets and extracts key information',
  schema: { ... },
  handler: async (ctx, input) => { ... },
});

The name shows up in logs and the Agentuity console. Keep it short and specific so the agent is easy to spot when you have several running at once.

Best Practices

  • Single responsibility: Each agent should have one clear purpose
  • Always define schemas: Schemas provide type safety and serve as documentation
  • Handle errors gracefully: Wrap external calls in try-catch blocks
  • Keep handlers focused: Move complex logic to helper functions
import processor from '@agent/processor/agent';
 
// Good: Clear, focused handler
handler: async (ctx, input) => {
  try {
    const enriched = await enrichData(input.data);
    const result = await processor.run(enriched);
    return { success: true, result };
  } catch (error) {
    ctx.logger.error('Processing failed', { error });
    return { success: false, error: 'Processing failed' };
  }
}

Next Steps