Robutler

Agent Endpoints

Expose custom HTTP API endpoints for your agent using the @http decorator. Endpoints are mounted under the agent's base path and are served by the same app used for chat completions.

  • Simple, declarative decorator: TypeScript @http({ path, method, scopes }), Python @http("/path", method="get|post", scope="...").
  • Path parameters and query strings supported.
  • Scope-based access control (all, owner, admin).
  • Plays nicely with skills, tools, and hooks.

Basic Usage

import { BaseAgent, Skill, http, serve } from 'webagents';

class StatusSkill extends Skill {
  readonly name = 'status';

  @http({ path: '/status', method: 'GET' })
  async getStatus(_req: Request): Promise<Response> {
    return Response.json({ status: 'healthy' });
  }
}

const agent = new BaseAgent({
  name: 'assistant',
  model: 'openai/gpt-4o-mini',
  skills: [new StatusSkill()],
});

await serve(agent, { port: 8000 });

Both expose GET /assistant/status.

Methods, Path, and Query

class UsersSkill extends Skill {
  readonly name = 'users';

  @http({ path: '/users', method: 'GET' })
  async listUsers(_req: Request): Promise<Response> {
    return Response.json({ users: ['alice', 'bob', 'charlie'] });
  }

  @http({ path: '/users', method: 'POST' })
  async createUser(req: Request): Promise<Response> {
    const data = await req.json();
    return Response.json({ created: data.name, id: 'user_123' });
  }

  @http({ path: '/users/:userId', method: 'GET' })
  async getUser(req: Request): Promise<Response> {
    const url = new URL(req.url);
    const userId = url.pathname.split('/').pop()!;
    const includeDetails = url.searchParams.get('include_details') === 'true';
    const user: Record<string, unknown> = { id: userId, name: `User ${userId}` };
    if (includeDetails) user.details = 'Extended info';
    return Response.json(user);
  }
}

Example requests:

# List users
curl http://localhost:8000/assistant/users

# Create user
curl -X POST http://localhost:8000/assistant/users \
  -H "Content-Type: application/json" \
  -d '{"name": "dana"}'

# Get user with query param
curl "http://localhost:8000/assistant/users/42?include_details=true"

# Missing or wrong Content-Type
curl -X POST http://localhost:8000/assistant/users -d '{"name":"dana"}'
# -> 415 Unsupported Media Type

# Wrong method
curl -X GET http://localhost:8000/assistant/users -H "Content-Type: application/json" -d '{}'
# -> 405 Method Not Allowed

# Unauthorized scope
curl http://localhost:8000/assistant/admin/metrics
# -> 403 Forbidden

Capability Discovery

Use provides (Python) / inferred capability (TypeScript via skill name) to declare what an endpoint provides:

@http({ path: '/export/pdf', method: 'POST', description: 'Export data as PDF' })
async exportPdf(req: Request): Promise<Response> {
  const data = await req.json();
  const pdf = await generatePdf(data);
  return new Response(pdf, { headers: { 'content-type': 'application/pdf' } });
}

@http({ path: '/api/search', method: 'GET', description: 'Search API endpoint' })
async search(req: Request): Promise<Response> {
  const query = new URL(req.url).searchParams.get('query') ?? '';
  return Response.json({ results: await performSearch(query) });
}

The provides value is included in the agent's capabilities for discovery.

Access Control (Scopes)

Use scopes (TypeScript) / scope (Python) to restrict who can call an endpoint:

@http({ path: '/public', method: 'GET', scopes: ['all'] })
async publicEndpoint(_req: Request): Promise<Response> {
  return Response.json({ message: 'Public data' });
}

@http({ path: '/owner-info', method: 'GET', scopes: ['owner'] })
async ownerEndpoint(_req: Request): Promise<Response> {
  return Response.json({ private: 'owner data' });
}

@http({ path: '/admin/metrics', method: 'GET', scopes: ['admin'] })
async adminMetrics(_req: Request): Promise<Response> {
  return Response.json({ rps: 100, error_rate: 0.001 });
}

WebSocket Endpoints

For bidirectional real-time communication, use the @websocket decorator:

import { Skill, websocket } from 'webagents';
import type { Context } from 'webagents';

class StreamSkill extends Skill {
  readonly name = 'stream';

  @websocket({ path: '/stream' })
  handleStream(ws: WebSocket, ctx: Context): void {
    ws.onmessage = async (ev) => {
      const message = JSON.parse(String(ev.data));
      const response = await this.process(message);
      ws.send(JSON.stringify(response));
    };
  }

  private async process(msg: unknown) { return { echo: msg }; }
}

Both expose WS /assistant/stream.

WebSocket with LLM Streaming

Combine WebSocket with handoffs for streaming chat:

import { Skill, websocket } from 'webagents';

class StreamingSkill extends Skill {
  readonly name = 'streaming';

  @websocket({ path: '/chat' })
  async handleChat(ws: WebSocket): Promise<void> {
    ws.onmessage = async (ev) => {
      const { messages = [] } = JSON.parse(String(ev.data));
      for await (const chunk of this.executeHandoff(messages)) {
        ws.send(JSON.stringify(chunk));
      }
    };
  }

  private async *executeHandoff(_: unknown[]) {
    yield { delta: 'streaming chunk' };
  }
}

SSE Streaming (Server-Sent Events)

Return an async iterable from an @http handler to stream as SSE:

import { Skill, http } from 'webagents';

class EventsSkill extends Skill {
  readonly name = 'events';

  @http({ path: '/events', method: 'GET', content_type: 'text/event-stream' })
  async streamEvents(_req: Request): Promise<Response> {
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      async start(controller) {
        for (let i = 0; i < 5; i++) {
          controller.enqueue(encoder.encode(`data: {"count": ${i}}\n\n`));
          await new Promise((r) => setTimeout(r, 1000));
        }
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
      },
    });
    return new Response(stream, {
      headers: {
        'content-type': 'text/event-stream',
        'cache-control': 'no-cache',
        connection: 'keep-alive',
      },
    });
  }
}

The Python server automatically sets SSE headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive). In TypeScript, set them yourself on the Response.

Auto-Registration via Transport Skills

Transport skills register endpoints automatically when added to an agent — no manual endpoint wiring needed:

import { BaseAgent } from 'webagents';
import { CompletionsTransportSkill } from 'webagents/skills/transport/completions';
import { A2ATransportSkill } from 'webagents/skills/transport/a2a';
import { UAMPTransportSkill } from 'webagents/skills/transport/uamp';

const agent = new BaseAgent({
  name: 'my-agent',
  skills: [
    new CompletionsTransportSkill(), // POST /v1/chat/completions, GET /v1/models
    new A2ATransportSkill(),         // POST /a2a, GET /.well-known/agent.json
    new UAMPTransportSkill(),        // WS /uamp
  ],
});

Tips

  • Keep one responsibility per endpoint (CRUD-style patterns work well).
  • Prefer GET for retrieval, POST for creation/processing.
  • Validate inputs inside handlers; return JSON-serializable data.
  • Register endpoints through skill classes alongside @tool, @hook, and @handoff.

See Also

  • Quickstart — serving agents
  • Agent Skills — modular capabilities
  • Tools — add executable functions
  • Hooks — lifecycle integration

On this page