Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cross-channel-output-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Allow cross-channel `receive(...)` calls from routes and schedules to request turn-scoped structured output with Standard JSON Schema or raw JSON Schema. Fresh conversation deliveries also clear schemas retained by earlier failed turns while active runtime and HITL continuations preserve them.
15 changes: 13 additions & 2 deletions docs/channels/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Declare routes with the `POST()` and `GET()` helpers. Each route handler receive

- `send(message, { auth, continuationToken, state? })` starts or resumes a session. Returns a `Session`.
- `getSession(sessionId)` looks up an existing session. The returned `Session` exposes `getEventStream({ startIndex? })` for streaming.
- `receive(channel, ...)` hands inbound work to a different channel for cross-channel hand-off.
- `receive(channel, { message, target, auth, outputSchema? })` hands inbound work to a different channel for cross-channel hand-off.
- `params` holds route parameters extracted from the path pattern.
- `waitUntil(promise)` extends the request lifetime for background work.
- `requestIp` is the client IP, or `null` when the host cannot provide it.
Expand Down Expand Up @@ -102,8 +102,14 @@ Route handlers can start a session on a different channel via `args.receive(chan

```ts
import { defineChannel, POST } from "eve/channels";
import { z } from "zod";
import slack from "./slack.js";

const investigationSchema = z.object({
summary: z.string(),
severity: z.enum(["low", "medium", "high"]),
});

export default defineChannel({
routes: [
POST("/incident", async (req, args) => {
Expand All @@ -112,6 +118,7 @@ export default defineChannel({
args.waitUntil(
args.receive(slack, {
message: `Investigate ${incident.reference}: ${incident.title}`,
outputSchema: investigationSchema,
target: { channelId: "C0123ABC" },
auth: {
authenticator: "incidentio",
Expand All @@ -130,11 +137,15 @@ export default defineChannel({

Semantics:

- The target channel's authored `receive(input, { send })` hook owns the continuation-token format and initial state. Callers supply only `{ message, target, auth }`.
- The target channel's authored `receive(input, { send })` hook owns the continuation-token format and initial state. Callers supply `{ message, target, auth, outputSchema? }`.
- `outputSchema` accepts Standard JSON Schema (for example, Zod 4) or raw JSON Schema. eve applies it to the target's framework-provided `send`, so the target channel does not need schema-specific forwarding. The schema applies only to this turn; successful structured output emits `result.completed`.
- `receive(...)` still resolves to a `Session`, not the structured value. Read the result asynchronously from `result.completed` in an agent hook or the session stream.
- `auth` flows through to `session.auth.initiator` so the target's event handlers and the agent's tools can read who started the session.
- Calling `args.receive(...)` does not also start a session on the current channel. The inbound channel's response is whatever the route handler returns explicitly.
- The first argument is the target channel module's default export. Import it directly from `agent/channels/<name>.ts`. Identity is matched by reference.

See [Output Schema](../guides/client/output-schema) for schema formats, result events, and per-turn behavior.

## Channel metadata

A channel can project a subset of its adapter state as metadata, available to instrumentation resolvers, dynamic tool resolvers, and dynamic skill or instruction resolvers. Define a `metadata(state)` function on the channel config:
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/client/output-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ console.log(result.data?.count);

`result.data` is `undefined` when the turn did not produce a structured result.

## Standard Schema
## Standard JSON Schema

The client also accepts Standard Schema implementations such as Zod, Valibot, and ArkType. The schema is lowered to JSON Schema before the request is sent:
The client also accepts schemas that implement [Standard JSON Schema](https://standardschema.dev/json-schema), such as Zod 4. The schema is lowered to plain JSON Schema before the request is sent. Libraries that implement only Standard Schema validation need their JSON Schema adapter first; for example, Valibot provides `toStandardJsonSchema` through `@valibot/to-json-schema`.

```ts
import { z } from "zod";
Expand Down
34 changes: 33 additions & 1 deletion docs/schedules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,44 @@ export default defineSchedule({
});
```

- `receive(channel, { message, target, auth })`: starts a session on another channel. Same contract as a route handler's `args.receive`.
- `receive(channel, { message, target, auth, outputSchema? })`: starts a session on another channel. Same contract as a route handler's `args.receive`; `outputSchema` accepts Standard JSON Schema (for example, Zod 4) or raw JSON Schema for this turn.
- `waitUntil(promise)`: extends the cron task's lifetime so the parked session and any in-flight fetches settle before the task ends. Wrap the `receive` call in it.
- `appAuth`: the app principal (`{ authenticator: "app", principalId: "eve:app", principalType: "runtime" }`). Pass it as `receive(..., { auth: appAuth })` for work the agent does on its own behalf.

A handler-form session runs on the same durable runtime engine as any other session, so it can park (durably suspend), for instance when the channel handoff is waiting for a Slack reply. Only markdown task mode is barred from waiting.

### Structured handoffs

Pass `outputSchema` when a scheduled handoff needs a structured result:

```ts title="agent/schedules/daily-assessment.ts"
import { defineSchedule } from "eve/schedules";
import { z } from "zod";

import slack from "../channels/slack.js";

const assessmentSchema = z.object({
summary: z.string(),
priority: z.enum(["low", "medium", "high"]),
});

export default defineSchedule({
cron: "0 9 * * 1-5",
run({ receive, waitUntil, appAuth }) {
waitUntil(
receive(slack, {
message: "Assess today's queue and summarize the priorities.",
target: { channelId: "C0123ABC" },
auth: appAuth,
outputSchema: assessmentSchema,
}),
);
},
});
```

Adding a schema does not change the target's run mode or durability. The schema applies only to the delivered turn, and successful structured completion emits `result.completed` for an [agent hook](./guides/hooks) or session event consumer. `receive(...)` still resolves to a `Session`, so read the structured value asynchronously from that event rather than from the return value.

## Trigger a schedule while iterating

The dev server mounts a one-shot dispatch route that fires a schedule by name, out of band, exactly once. Since `eve dev` never runs schedules on their cron cadence, this is how you trigger one without waiting for the next production tick.
Expand Down
19 changes: 17 additions & 2 deletions e2e/fixtures/agent-channels/agent/channels/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export default defineChannel({
const body = (await req.json().catch(() => ({}))) as {
message?: string;
sessionRef?: string;
structured?: boolean;
};
const session = await args.receive(target, {
const options = {
message: body.message ?? "Reply with the single word: hello.",
target: { sessionRef: body.sessionRef ?? crypto.randomUUID() },
auth: {
Expand All @@ -23,7 +24,21 @@ export default defineChannel({
principalId: "smoke-test",
principalType: "service",
},
});
} as const;
const session = body.structured
? await args.receive(target, {
...options,
outputSchema: {
additionalProperties: false,
properties: {
count: { type: "integer" },
title: { type: "string" },
},
required: ["title", "count"],
type: "object",
},
})
: await args.receive(target, options);
return Response.json({ ok: true, sessionId: session.id });
}),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { defineEval } from "eve/evals";

import { postChannel } from "./shared.js";

/** Structured-output smoke for the cross-channel `args.receive` handoff. */
export default defineEval({
description: "Custom channel smoke: structured cross-channel receive.",

async test(t) {
const payload = await postChannel<{ ok: boolean; sessionId?: string }>(t.target, "/webhook", {
message:
'Return exactly one structured result with title "handoff" and count 1. Do not answer in prose.',
structured: true,
});
if (payload.ok !== true || typeof payload.sessionId !== "string") {
throw new Error(`Unexpected webhook response: ${JSON.stringify(payload)}`);
}

const session = await t.target.attachSession(payload.sessionId);
const results = session.events.filter((event) => event.type === "result.completed");
if (results.length !== 1) {
const failures = session.events.filter(
(event) =>
event.type === "session.failed" ||
event.type === "turn.failed" ||
event.type === "step.failed",
);
throw new Error(
`Expected one result.completed event, received ${results.length}. ` +
`Observed events: ${session.events.map((event) => event.type).join(", ")}. ` +
`Failures: ${JSON.stringify(failures)}.`,
);
}

const result = results[0]?.data.result;
if (
!isRecord(result) ||
typeof result.title !== "string" ||
typeof result.count !== "number" ||
!Number.isInteger(result.count) ||
Object.keys(result).some((key) => key !== "count" && key !== "title") ||
Object.keys(result).length !== 2
) {
throw new Error(`Unexpected structured result: ${JSON.stringify(result)}`);
}

t.didNotFail();
t.completed();
},
});

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading
Loading