@agentforge-io/nest
v4.2.1
Published
NestJS bindings for AgentForge — `AgentForgeModule.forRoot/forRootAsync` plus controllers, guards and decorators. Wraps @agentforge-io/core.
Readme
AgentForge
Framework NestJS para construir micro-SaaS de Agents as a Service usando Claude Agent SDK.
AgentForge abstrae toda la complejidad de crear un sistema multi-agente con monetización, persistencia de conversaciones, procesamiento en background y billing con Stripe — para que te puedas enfocar solo en definir tus agentes y herramientas.
Características
- Multi-agente con orquestación automática — Un agente orquestador puede delegar tareas a subagentes especializados. Claude decide cuándo y cómo delegar.
- Persistencia de conversaciones en Postgres — Historial completo con TypeORM, incluyendo tool calls y uso de tokens.
- Queue de tareas async con BullMQ + Redis — Procesa conversaciones largas en background, el cliente hace polling o recibe el resultado cuando está listo.
- Billing con Stripe incluido — Planes freemium, suscripciones mensuales/anuales, pay-per-use y créditos. Un adaptador para cambiar de proveedor de pagos.
- Tracking de uso por usuario — Límites de requests y tokens por plan, aplicados automáticamente.
- Framework HTTP: NestJS — DI, decoradores, guards, SSE streaming, Swagger.
- TypeScript nativo — Tipado completo, autocompletado total.
Instalación
npm install agentforge @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjsRequerimientos:
- Node.js ≥ 18
- PostgreSQL ≥ 14
- Redis ≥ 6 (solo si usas queue)
Quickstart (5 minutos)
1. Configurar el módulo raíz
// app.module.ts
import { Module } from '@nestjs/common';
import { AgentForgeModule } from 'agentforge';
@Module({
imports: [
AgentForgeModule.forRoot({
anthropic: {
apiKey: process.env.ANTHROPIC_API_KEY!,
},
database: {
url: process.env.DATABASE_URL!,
},
agents: [
{
id: 'assistant',
name: 'Assistant',
systemPrompt: 'You are a helpful assistant.',
},
],
}),
],
})
export class AppModule {}2. Arrancar la app
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);
}
bootstrap();3. Usar la API REST
AgentForge registra automáticamente las rutas:
# Crear una conversación
POST /conversations
{ "userId": "user_123", "agentId": "assistant" }
# Enviar un mensaje
POST /conversations/:id/messages
{ "userId": "user_123", "content": "Hello!" }
# Ver el historial
GET /conversations/:id?userId=user_123
# Listar conversaciones
GET /conversations?userId=user_123
# Stream de respuesta (Server-Sent Events)
GET /conversations/:id/stream?userId=user_123&content=HelloConfiguración completa
AgentForgeModule.forRoot({
// ─── Anthropic ─────────────────────────────────────────────────────────
anthropic: {
apiKey: 'sk-ant-...',
defaultModel: 'claude-opus-4-6', // Modelo por defecto para todos los agentes
defaultMaxTokens: 4096,
},
// ─── Base de datos ─────────────────────────────────────────────────────
database: {
url: 'postgresql://user:pass@localhost:5432/myapp',
autoMigrate: true, // Sincroniza el schema automáticamente (usa false en prod)
logging: false,
},
// ─── Redis (necesario para queue) ──────────────────────────────────────
redis: {
host: 'localhost',
port: 6379,
},
// ─── Queue async ───────────────────────────────────────────────────────
queue: {
enabled: true,
concurrency: 5,
},
// ─── Billing ───────────────────────────────────────────────────────────
billing: {
provider: 'stripe',
models: ['subscription', 'freemium'],
appUrl: 'https://myapp.com',
stripe: {
secretKey: 'sk_live_...',
webhookSecret: 'whsec_...',
},
plans: [
{
id: 'free',
name: 'Free',
isDefault: true,
limits: { requestsPerMonth: 50, tokensPerMonth: 25_000 },
},
{
id: 'pro',
name: 'Pro',
stripePriceId: 'price_xxx',
priceInCents: 1900,
interval: 'month',
limits: { requestsPerMonth: 1000, tokensPerMonth: 1_000_000 },
features: ['All agents', 'Priority support'],
},
],
},
// ─── Agentes ───────────────────────────────────────────────────────────
agents: [
{
id: 'assistant',
name: 'General Assistant',
systemPrompt: 'You are a helpful assistant.',
model: 'claude-opus-4-6',
tools: ['calculator', 'web_search'],
},
],
})Multi-Agente con Orquestación
Define un agente orquestador con canOrchestrate: true y lista sus subagentes. AgentForge inyecta automáticamente herramientas de delegación para que Claude sepa cuándo llamar a cada especialista.
agents: [
// Especialistas
{
id: 'researcher',
name: 'Research Specialist',
systemPrompt: 'You are an expert researcher. Find and summarize information.',
tools: ['web_search'],
},
{
id: 'writer',
name: 'Content Writer',
systemPrompt: 'You are a professional writer. Create high-quality content.',
},
{
id: 'analyst',
name: 'Data Analyst',
systemPrompt: 'You analyze data and provide insights.',
tools: ['calculator', 'query_data'],
},
// Orquestador — coordina a los especialistas
{
id: 'orchestrator',
name: 'AI Director',
systemPrompt: `You coordinate specialist agents for complex tasks.
Delegate research to the researcher, writing to the writer, and analysis to the analyst.
Synthesize all results into a final comprehensive answer.`,
canOrchestrate: true,
subAgents: ['researcher', 'writer', 'analyst'],
},
]Cuando el usuario envía un mensaje al orquestador, Claude puede llamar a delegate_to_researcher, delegate_to_writer, etc. automáticamente.
Definir Tools
Opción 1: Función defineTool (recomendada)
import { defineTool, AgentForgeModule } from 'agentforge';
const weatherTool = defineTool({
name: 'get_weather',
description: 'Get current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' },
},
required: ['city'],
},
// Opcional: restringir a agentes específicos
agents: ['assistant', 'orchestrator'],
execute: async ({ city }, context) => {
// context tiene: userId, conversationId, agentId, messageId
const data = await fetchWeatherAPI(city as string);
return `${city}: ${data.temp}°C, ${data.condition}`;
},
});
// Registrar en el módulo
AgentForgeModule.forRootAsync({
useFactory: (config: ConfigService) => ({
agents: [...],
...
}),
options: {
tools: [weatherTool, calculatorTool, ...],
},
})Opción 2: Registro manual en tiempo de ejecución
import { ToolRegistryService } from 'agentforge';
@Injectable()
export class MyService {
constructor(private readonly toolRegistry: ToolRegistryService) {}
onModuleInit() {
this.toolRegistry.register({
name: 'my_tool',
description: '...',
inputSchema: { type: 'object', properties: {} },
execute: async (input) => 'result',
});
}
}Billing
API de Billing
# Ver planes disponibles
GET /billing/plans
# Crear sesión de checkout (Stripe Checkout)
POST /billing/checkout
{ "userId": "user_123", "planId": "pro" }
→ { "url": "https://checkout.stripe.com/..." }
# Portal de cliente (gestionar suscripción)
POST /billing/portal
{ "userId": "user_123" }
→ { "url": "https://billing.stripe.com/..." }
# Ver estado de billing del usuario
GET /billing/overview/user_123
# Cancelar suscripción
DELETE /billing/subscription/user_123
# Webhook de Stripe
POST /billing/webhook (con header stripe-signature)Agregar créditos
billing: {
models: ['credits'],
credits: {
tokensPerCredit: 1000, // 1 crédito = 1000 tokens
creditsPerRequest: 1, // +1 crédito por request
initialCredits: 100, // Créditos iniciales al registrarse
},
}Adaptador personalizado (PayPal, Paddle, LemonSqueezy, etc.)
import { IBillingAdapter } from 'agentforge';
export class PaddleAdapter implements IBillingAdapter {
async createCheckoutSession(params) { ... }
async createSubscription(params) { ... }
async cancelSubscription(id, atPeriodEnd) { ... }
async handleWebhook(payload, signature) { ... }
async getPortalUrl(customerId, returnUrl) { ... }
async createCustomer(params) { ... }
async getSubscription(id) { ... }
}
// En la config:
billing: {
provider: 'custom',
customAdapter: PaddleAdapter,
}Queue asíncrona
Envía mensajes en background y haz polling del resultado:
// Enviar mensaje asíncrono
POST /conversations/:id/messages
{ "userId": "user_123", "content": "...", "async": true }
→ { "jobId": "abc123", "estimatedWait": 5000 }
// Hacer polling
GET /jobs/abc123
→ { "status": "active", "progress": 70 }
→ { "status": "completed", "result": { "content": "..." } }Guards y decoradores
@RequirePlan() — Restricciones por plan
import { RequirePlan, UsageLimitGuard } from 'agentforge';
@Controller('conversations')
@UseGuards(UsageLimitGuard) // Aplica límites de uso automáticamente
export class MyController {
@Post()
@RequirePlan('pro', 'enterprise') // Solo usuarios en plan pro o enterprise
async createConversation(@Body() dto: CreateConversationDto) {
return this.agentService.createConversation(dto);
}
}UsageLimitGuard — Límites automáticos
Aplica el guard globalmente en main.ts:
import { UsageLimitGuard } from 'agentforge';
app.useGlobalGuards(app.get(UsageLimitGuard));Acceso a servicios internos
Todos los servicios se exportan para que puedas inyectarlos en tu propia lógica:
import {
AgentService,
BillingService,
ConversationService,
UsageService,
ToolRegistryService,
OrchestratorService,
} from 'agentforge';
@Injectable()
export class MyCustomService {
constructor(
private readonly agents: AgentService,
private readonly billing: BillingService,
private readonly usage: UsageService,
) {}
async onboardUser(userId: string, email: string) {
// Crear conversación de bienvenida
const { conversation } = await this.agents.createConversation({
userId,
agentId: 'assistant',
title: 'Welcome',
initialMessage: 'Hello! I am your AI assistant. How can I help you today?',
});
// Ver el uso
const summary = await this.usage.getSummary(userId);
console.log(`User ${userId}: ${summary.requestsUsed}/${summary.requestsLimit} requests used`);
return conversation;
}
}Host turn resolver (caller identity, per-turn context, sign-in gate)
A host can tell the SDK who is talking and what to add to the model call
without forking any controller. Bind a HostTurnResolver and the controllers
consult it once per request:
AgentForgeModule.forRootAsync({
useFactory: () => config,
options: {
// or `hostTurnResolverFactory: { inject: [...], useFactory }` when you need DI
hostTurnResolver: {
timeoutMs: 1500, // default 2000
async resolve(input) {
// input: { req, route, action, callsModel, defaultUserId, chatToken?,
// tenantId?, browserSessionId?, agentId?, conversationId? }
return {
userId: 'contact:42', // public chat only
overrides: { turnContext: '## Who you are talking to ...' },
// gate: 'auth_required', authRequired: { methods: ['email'] },
};
},
},
},
});You can also provide the AGENT_FORGE_HOST_TURN_RESOLVER token yourself, but
only from a @Global() module (otherwise the SDK's controllers cannot see it).
With no resolver the behaviour is exactly the one before this feature.
| Route | Consulted on | userId | overrides | gate |
|---|---|---|---|---|
| PublicChatController (/public/chat/:token/*) | every route: create, send, both streams, get, end, list, rename, share, unshare | honoured, used for the whole request | honoured when the request calls the model | honoured when the request calls the model |
| AgentController (/conversations/*) | routes that call the model: send, prepared stream, create with initialMessage | ignored (authenticated caller) | honoured | ignored |
| JobsController (POST /jobs) | enqueue | ignored | honoured, except extraTools | ignored |
- One identity per request. The public chat resolves the caller once and
uses that
userIdfor the ownership check, the transcript read and the model call, so a conversation can never be read under one identity and written under another. Use a prefixed synthetic id (contact:<id>). - Overrides the resolver may return:
turnContext,extraTools,surface,traceContext,systemPromptSuffix(anything else is dropped). They are merged after the sanitised client overrides and win.extraToolsare not forwarded onPOST /jobs(the payload is serialised onto the queue; closures cannot be). - Failure policy. If
resolvethrows, rejects or exceedstimeoutMs, a warning is logged and the request proceeds as if no resolver were bound (public chat: anonymousbrowser:<id>, no host overrides). The chat keeps working; a future sign-in wall therefore fails open. gate: 'auth_required'(public chat) refuses the turn before any model call and before the user message is stored.- SSE routes emit one
auth_requiredevent and close (nodoneframe):event: auth_required/data: {"error":"auth_required","message":"Sign-in required to continue.","authRequired":<payload|null>}. - Non-stream routes answer 401 with
{ "statusCode": 401, "error": "auth_required", "message": "...", "authRequired": <payload|null> }. - Widgets older than the sign-in card do not understand the event (see the CHANGELOG note); do not enable a gate for them.
- SSE routes emit one
Client overrides allow-list
POST /conversations/:id/messages and POST /jobs accept an overrides object
from the request body. Only model, temperature, maxTokens and
systemPromptSuffix survive (sanitizeClientOverrides, exported). turnContext,
extraTools, surface, traceContext and every unknown key are dropped: they
belong to the host and must never come from a client.
Public chat: "My conversations", rename and share links
Scoped exactly like the other public chat routes: the chat token picks the
agent (for a team: its orchestrator), the resolved caller (browser:<id>
unless a host turn resolver re-identifies it) picks the owner, and a
conversation of another browser session or another agent answers the same 404
as an unknown one. Conversation ids are the short public codes.
| Method + path | Body / query | Response |
|---|---|---|
| GET /public/chat/:token/conversations | ?browserSessionId=&limit=(1..50, default 20)&offset= | { conversations: [{ id, title, preview, status, shared, createdAt, updatedAt }], hasMore, nextOffset } — newest activity first |
| PATCH /public/chat/:token/conversations/:id | { title, browserSessionId } | { id, title } — 400 invalid_title when empty / > 120 chars after normalising |
| POST /public/chat/:token/conversations/:id/share | { browserSessionId } | { token, path: "/share/<token>", reused, createdAt } — 422 empty_conversation, 409 share_limit_reached |
| POST /public/chat/:token/conversations/:id/share/revoke | { browserSessionId, shareToken? } | { revoked } — one link, or all of the conversation's when shareToken is omitted |
| GET /public/shared-chats/:shareToken | — (no chat token) | { title, agent: { name, avatarUrl } \| null, messages: [{ role, content }], truncated, createdAt } — 404 for unknown / revoked |
A share is a snapshot: plain user/assistant text copied when the link is
created (no tool calls, cards, metadata or ids). Sharing again while nothing
changed returns the same link (reused: true); after new messages or a rename
it returns a new link, and older links keep showing their snapshot until
revoked. Use @agentforge-io/chat-sdk (ChatSession.listConversations /
renameConversation / shareConversation / revokeShare, fetchSharedChat)
rather than calling these routes by hand.
The snapshot's agent.name is the resolved agent's name, unless the host's
AgentResolver sets a non-blank string appearance.displayName on the agent
(next to appearance.avatarUrl), which then wins. Set it when the agent's own
name is internal — e.g. the team name for a team's hidden orchestrator
("<Team> (orchestrator)"). It changes only the share snapshot's name; like
the rest of appearance, it is also visible in the theme of
GET /public/chat/:token/agent, so put only a public name there.
Variables de entorno
# Anthropic
ANTHROPIC_API_KEY=sk-ant-api03-...
# PostgreSQL
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# App
APP_URL=http://localhost:3000Estructura de la base de datos
AgentForge crea automáticamente las siguientes tablas (prefijo af_):
| Tabla | Descripción |
|---|---|
| af_users | Usuarios con plan actual y balance de créditos |
| af_conversations | Sesiones de conversación con agentes |
| af_messages | Historial de mensajes con tool calls y uso de tokens |
| af_subscriptions | Suscripciones de Stripe por usuario |
| af_usage_records | Registro detallado de uso para billing |
Roadmap
- [ ] Soporte para MCP servers (Model Context Protocol)
- [ ] Adapter para LemonSqueezy y Paddle
- [ ] Panel de administración (React)
- [ ] Rate limiting por IP
- [ ] Webhooks de eventos para tu sistema (conversación iniciada, límite alcanzado, etc.)
- [ ] Soporte para archivos adjuntos en mensajes
- [ ] Streaming con WebSockets (además de SSE)
Licencia
MIT
