Guides
Next.js
Use the NextEndpoint adapter to expose your agent network as a Next.js App Router API route.
Setup
- Install m4trix:
pnpm add @m4trix/core- Create an API route (e.g.
app/api/chat/route.ts):
import {
AgentFactory,
AgentNetwork,
AgentNetworkEvent,
registerSSEStream,
NextEndpoint,
S,
} from '@m4trix/core/matrix';
const requestEvent = AgentNetworkEvent.of('chat-request', S.Struct({ message: S.String }));
const responseEvent = AgentNetworkEvent.of('chat-response', S.Struct({ text: S.String, done: S.Boolean }));
const agent = AgentFactory.run()
.listensTo([requestEvent])
.emits([responseEvent])
.logic(async ({ triggerEvent, emit }) => {
emit({
name: 'chat-response',
payload: { text: `Echo: ${triggerEvent.payload.message}`, done: true },
});
})
.produce({});
const network = AgentNetwork.setup(
({ mainChannel, createChannel, proxy, registerAgent, endsOn }) => {
const client = createChannel('client').proxy(proxy.sse());
registerAgent(agent).subscribe(mainChannel).publishTo(client);
// Close the response once the final chunk is out.
endsOn(responseEvent, (event) => event.payload.done);
},
);
const api = network.expose(
registerSSEStream({
channel: 'client',
triggerEvents: [requestEvent],
}),
);
const handler = NextEndpoint.from(api).handler();
export const GET = handler;
export const POST = handler;How It Works
- Receives the incoming
Request - Runs auth (if configured)
- Extracts the JSON payload from the POST body (or a GET's
?payload=, else{}) and validates it againsttriggerEvents: malformed JSON or a payload the schema rejects is answered with a 400 and{ error: 'invalid_request', issues }before any run starts (see Request validation) - Creates an SSE
Responsewith streaming headers - Publishes the start event to the main channel
- Streams the run's events from the selected channel as SSE
- Ends the response after
m4trix:run.completedorm4trix:run.failed(see Run Lifecycle). If the client disconnects first and does not reconnect withinreconnectWindow(default 30 seconds), the run is cancelled and agents'signalaborts
Along the way it writes an id: per durable event, a : ping heartbeat every 15 seconds (NextEndpoint.from(api, { heartbeat: '30 seconds' }), Infinity disables), and sends X-Accel-Buffering: no. A failure after the response started is logged and ends the stream with m4trix:run.failed (kind: 'stream-error').
Reconnects with EventSource
EventSource reconnects by itself and sends the last id: as Last-Event-ID; the same route then resumes the run instead of starting a new one (a 404 when there is nothing to resume, which stops the reconnects). The run stays alive across drops of up to reconnectWindow (30 seconds by default); close the source on the run-end event:
// route.ts
const api = network.expose(
registerSSEStream({ channel: 'client', triggerEvents: [requestEvent] }), // reconnectWindow: '30 seconds' by default
);
// client
const source = new EventSource(`/api/chat?payload=${encodeURIComponent(JSON.stringify({ message }))}`);
source.addEventListener('chat-response', (e) => append(JSON.parse(e.data).payload.text));
for (const end of ['m4trix:run.completed', 'm4trix:run.failed', 'm4trix:run.cancelled']) {
source.addEventListener(end, () => source.close());
}See Resuming a stream for what is replayed and when.
Conversations and auth
The client picks the conversation with a header, read by requestToContextId. That id is client-supplied, so return the signed-in user as the principal from auth: history is looked up under (principal, contextId), and a client can never reach another user's conversation by sending its id.
const api = network.expose(
registerSSEStream({
channel: 'client',
triggerEvents: [requestEvent],
auth: async (req) => {
const session = await getSession(req.request); // your auth library
return session
? { allowed: true, principal: { id: session.user.id } }
: { allowed: false, status: 401, message: 'Sign in first' };
},
}),
);
const handler = NextEndpoint.from(api, {
requestToContextId: (req) => req.headers.get('x-correlation-id') ?? crypto.randomUUID(),
requestToRunId: () => crypto.randomUUID(),
}).handler();Without auth (or when it allows a request without a principal), requests are anonymous and share history by contextId alone. See Auth + Multi-Tenant.
GET vs POST
- GET — The payload is URL-encoded JSON in
?payload=(e.g.?payload=%7B%22message%22%3A%22Hi%22%7D), validated exactly like a POST body: malformed JSON or a schema mismatch is a 400.EventSourcecan only GET. - POST — Payload in JSON body. Preferred for
fetch-based clients.
Full Example with OpenAI Streaming
See IO + Adapters for a complete Next.js streaming example with OpenAI.