experimental-ash 0.16.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/docs/internals/schedule.md +37 -26
  3. package/dist/docs/public/channels/README.md +71 -0
  4. package/dist/docs/public/schedules.md +77 -49
  5. package/dist/src/channel/cross-channel-receive.d.ts +61 -0
  6. package/dist/src/channel/cross-channel-receive.js +50 -0
  7. package/dist/src/channel/receive-args.d.ts +17 -0
  8. package/dist/src/channel/receive-args.js +1 -0
  9. package/dist/src/channel/routes.d.ts +9 -0
  10. package/dist/src/channel/schedule.d.ts +45 -32
  11. package/dist/src/channel/schedule.js +56 -50
  12. package/dist/src/chunks/authored-module-loader-XcFLnl49.js +2 -0
  13. package/dist/src/chunks/{dev-authored-source-watcher-Tu9dhx5K.js → dev-authored-source-watcher-CG6kri3T.js} +1 -1
  14. package/dist/src/chunks/{host-C83crl7k.js → host-CIU0NATc.js} +6 -6
  15. package/dist/src/chunks/paths-CvbqpwTh.js +88 -0
  16. package/dist/src/chunks/{prewarm-CdxOi2uE.js → prewarm-C_Vd0JR7.js} +2 -2
  17. package/dist/src/cli/commands/info.js +1 -1
  18. package/dist/src/cli/run.js +1 -1
  19. package/dist/src/compiled/.vendor-stamp.json +1 -1
  20. package/dist/src/compiled/@vercel/sandbox/index.d.ts +8 -1
  21. package/dist/src/compiler/manifest.d.ts +6 -24
  22. package/dist/src/compiler/manifest.js +2 -8
  23. package/dist/src/compiler/normalize-channel.d.ts +0 -8
  24. package/dist/src/compiler/normalize-channel.js +0 -27
  25. package/dist/src/compiler/normalize-manifest.js +2 -10
  26. package/dist/src/compiler/normalize-schedule.d.ts +6 -12
  27. package/dist/src/compiler/normalize-schedule.js +9 -32
  28. package/dist/src/evals/cli/eval.js +1 -1
  29. package/dist/src/evals/runner/discover.js +1 -1
  30. package/dist/src/execution/sandbox/bindings/vercel.d.ts +2 -2
  31. package/dist/src/execution/sandbox/bindings/vercel.js +8 -1
  32. package/dist/src/internal/application/package.js +1 -1
  33. package/dist/src/internal/authored-definition/core.d.ts +3 -2
  34. package/dist/src/internal/authored-definition/core.js +20 -10
  35. package/dist/src/internal/authored-module-loader.d.ts +0 -6
  36. package/dist/src/internal/authored-module-loader.js +11 -72
  37. package/dist/src/internal/nitro/routes/agent-info/build-agent-info-response.js +3 -1
  38. package/dist/src/internal/nitro/routes/channel-dispatch.js +3 -0
  39. package/dist/src/internal/nitro/routes/runtime-stack.d.ts +0 -11
  40. package/dist/src/internal/nitro/routes/runtime-stack.js +0 -25
  41. package/dist/src/internal/nitro/routes/schedule-task.d.ts +3 -3
  42. package/dist/src/internal/nitro/routes/schedule-task.js +41 -11
  43. package/dist/src/public/channels/slack/index.d.ts +1 -1
  44. package/dist/src/public/channels/slack/slackChannel.d.ts +20 -1
  45. package/dist/src/public/channels/slack/slackChannel.js +25 -3
  46. package/dist/src/public/channels/twilio/twilioChannel.d.ts +2 -1
  47. package/dist/src/public/definitions/sandbox.d.ts +3 -3
  48. package/dist/src/public/definitions/schedule.d.ts +47 -50
  49. package/dist/src/public/definitions/schedule.js +10 -25
  50. package/dist/src/public/helpers/markdown.d.ts +6 -6
  51. package/dist/src/public/helpers/markdown.js +8 -8
  52. package/dist/src/public/sandbox/backends/vercel.d.ts +5 -5
  53. package/dist/src/public/sandbox/backends/vercel.js +3 -3
  54. package/dist/src/public/sandbox/index.d.ts +2 -2
  55. package/dist/src/public/sandbox/vercel-sandbox.d.ts +13 -0
  56. package/dist/src/public/schedules/index.d.ts +1 -1
  57. package/dist/src/public/schedules/index.js +1 -1
  58. package/dist/src/runtime/resolve-channel.js +1 -0
  59. package/dist/src/runtime/schedules/resolve-schedule.js +5 -5
  60. package/dist/src/runtime/types.d.ts +15 -10
  61. package/dist/src/shared/sandbox-backend.d.ts +7 -7
  62. package/dist/src/shared/sandbox-definition.d.ts +7 -12
  63. package/package.json +1 -1
  64. package/dist/src/chunks/authored-module-loader-Pt_g8xX2.js +0 -3
  65. package/dist/src/chunks/paths-KCBJzXn2.js +0 -88
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7304b4d: Replace the schedule `channel: receive(slack, args)` declarative form with
8
+ a `run` handler that receives the same `args.receive(channel, …)` route
9
+ handlers got in the previous release. Schedules now author one of:
10
+
11
+ - `markdown: "..."` — fire-and-forget agent invocation (channel-less,
12
+ output discarded). Markdown frontmatter form (`.md`) maps here.
13
+ - `async run({ receive, waitUntil, appAuth }) { ... }` — full handler.
14
+ `receive(channel, ...)` is the same cross-channel surface route
15
+ handlers use; `waitUntil(promise)` extends the cron task lifetime.
16
+
17
+ Migration:
18
+
19
+ ```ts
20
+ // before
21
+ export default defineSchedule({
22
+ cron: "0 9 * * 1-5",
23
+ markdown: "Post the digest.",
24
+ channel: receive(slack, { channelId: "C0123ABC" }),
25
+ });
26
+
27
+ // after
28
+ export default defineSchedule({
29
+ cron: "0 9 * * 1-5",
30
+ async run({ receive, waitUntil, appAuth }) {
31
+ waitUntil(
32
+ receive(slack, {
33
+ message: "Post the digest.",
34
+ args: { channelId: "C0123ABC" },
35
+ auth: appAuth,
36
+ })
37
+ );
38
+ },
39
+ });
40
+ ```
41
+
42
+ Pure-markdown schedules (both `.md` form and `.ts` form with `markdown`)
43
+ are unchanged.
44
+
45
+ Removed: the `receive(channel, args)` schedule helper, the
46
+ `ChannelReceiveRef` type, the `ChannelRouteIdentityMap` compile-time
47
+ plumbing, the `ash-channel-identity` Rolldown plugin, and the
48
+ `channel`/`schedule.channel` manifest entry. Authored schedule
49
+ identity is now derived entirely at runtime from the channel value the
50
+ handler imports, matching how `args.receive(channel, …)` already works
51
+ in route handlers.
52
+
53
+ ## 0.17.0
54
+
55
+ ### Minor Changes
56
+
57
+ - eaa612b: Add `args.receive(channel, …)` to route handlers for cross-channel
58
+ hand-off. A route handler can now start a session on a different
59
+ channel — e.g. an inbound HTTP webhook on one channel hands the
60
+ conversation off to Slack — by calling
61
+ `args.receive(targetChannel, { message, args, auth })`. The target
62
+ channel's authored `receive(input, { send })` hook owns the
63
+ continuation-token format and initial state; `auth` flows through to
64
+ `session.initiatorAuth`.
65
+
66
+ `slackChannel().receive` accepts an optional `initialPost` so callers
67
+ can post an anchor card before the agent runs; the agent's first turn
68
+ threads under that card. `threadTs` and `initialPost` are mutually
69
+ exclusive.
70
+
71
+ The schedule dispatcher's receive invocation now shares its
72
+ implementation with the route-handler path so both call sites stay
73
+ byte-identical.
74
+
75
+ ### Patch Changes
76
+
77
+ - 38fd773: fix(ash): fix broken sandbox `bootstrap()` to be aligned with `onSession()` but apply snapshot-wide
78
+
3
79
  ## 0.16.3
4
80
 
5
81
  ### Patch Changes
@@ -1,16 +1,18 @@
1
1
  # Schedules
2
2
 
3
3
  Schedules let an authored agent run on a cron cadence. The runtime owns the
4
- dispatch contract; the channel layer (when present) owns delivery.
4
+ dispatch contract; channels (when an author hands work off to one) own
5
+ delivery.
5
6
 
6
7
  ## Authoring shape
7
8
 
8
9
  Each schedule is a single file under `schedules/`:
9
10
 
10
11
  - `schedules/<name>.{ts,cts,mts,js,cjs,mjs}` — TypeScript module with a default
11
- export of `defineSchedule({ cron, markdown, channel? })`.
12
+ export of `defineSchedule({ cron, markdown? | run? })`. Exactly one of
13
+ `markdown` (fire-and-forget) or `run` (handler) is provided.
12
14
  - `schedules/<name>.md` — markdown with frontmatter (`cron:`) and the body as
13
- the prompt. The markdown form does not support a channel.
15
+ the prompt. Always fire-and-forget; markdown form cannot declare `run`.
14
16
 
15
17
  Recursive nesting is supported. The schedule name is derived from the relative
16
18
  path under `schedules/` minus the file extension
@@ -28,40 +30,45 @@ to the unified `discoverNamedSourceDirectory` walker with
28
30
  hydrates each `ScheduleSourceRef` into a `CompiledScheduleDefinition`:
29
31
 
30
32
  - TypeScript modules go through `loadModuleBackedDefinition` and
31
- `normalizeScheduleDefinition` (rejects unknown keys).
33
+ `normalizeScheduleDefinition` (rejects unknown keys; enforces the
34
+ `markdown` / `run` exclusivity rule).
32
35
  - Markdown sources have already been lowered at discovery time; the compiler
33
36
  re-runs `normalizeScheduleDefinition` for type safety.
34
- - When `channel` is present, the compiler resolves the imported route to a
35
- channel name through the `channelIdentityMap` built when channels were
36
- loaded. Plain `{ name, args }` literals are accepted as a test-fixture
37
- fallback.
38
- - The compiled `markdown` field is `definition.markdown.trim()`.
37
+ - The compiled entry carries `hasRun: boolean` plus an optional `markdown`
38
+ string. The schedule's TypeScript module (for `hasRun` schedules) is
39
+ loaded at dispatch time via the standard module map; the compiler does
40
+ not bake the handler into the manifest.
39
41
  - The schedule name is derived from the logical path
40
42
  (`stripLogicalPathExtension(...).replace(/^schedules\//, "")`).
41
43
 
42
44
  ## Runtime
43
45
 
44
- `ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`) takes
45
- two paths:
46
+ `ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`) builds
47
+ `ScheduleHandlerArgs` (a subset of `RouteHandlerArgs`) against the
48
+ request-scoped channel bundle and routes on the schedule shape:
46
49
 
47
- - **With a channel:** resolve the channel by name, call its `receive()` with
48
- `{ message: markdown, args, auth: APP_AUTH }`. The channel handles delivery
49
- through its normal code path.
50
- - **Without a channel:** start a session via `runtime.run({...})` directly with
51
- a minimal `{ kind: "schedule" }` adapter, `auth: APP_AUTH`, `mode: "task"`,
52
- and the schedule's `markdown` as the seed message. Output is discarded; the
53
- agent can still call tools, log, or hit backends.
50
+ - **Handler (`run`):** invoke `definition.run(args)`. The handler decides
51
+ control flow; `args.receive(channel, …)` hands the work off to a channel,
52
+ `args.waitUntil(promise)` keeps the Nitro task open until the promise
53
+ settles. `appAuth` is the framework app principal pre-built for
54
+ convenience.
55
+ - **Markdown:** the dispatcher synthesizes a channel-less run by calling
56
+ `runtime.run({ adapter: SCHEDULE_ADAPTER, mode: "task", auth: APP_AUTH,
57
+ input: { message: markdown } })`. Output is discarded; the agent can still
58
+ call tools, log, or hit backends.
54
59
 
55
60
  Both branches use the framework app principal: `principalId: "ash:app"`,
56
61
  `principalType: "runtime"`.
57
62
 
58
- ## Why markdown form omits channel
63
+ `SCHEDULE_ADAPTER` is registered in `FRAMEWORK_ADAPTERS`
64
+ (`runtime/channels/registry.ts`) so the adapter registry can rehydrate
65
+ `{ kind: "schedule" }` at every workflow step boundary.
59
66
 
60
- A typed `receive(channel, args)` reference depends on importing the channel
61
- module so the route's args type is in scope. Markdown frontmatter has no such
62
- import boundary args would have to be opaque `Record<string, unknown>`,
63
- losing the type safety that makes `receive()` worth using. The simple,
64
- type-safe answer is: if you need a channel, write a `.ts` schedule.
67
+ ## Why markdown form omits `run`
68
+
69
+ Markdown frontmatter has no JavaScript scope; declaring a handler requires
70
+ TypeScript anyway. Markdown stays the path for fire-and-forget prompts;
71
+ `.ts` schedules cover everything else.
65
72
 
66
73
  ## Nitro task wiring
67
74
 
@@ -73,7 +80,8 @@ event name plus a baked-in artifacts config.
73
80
 
74
81
  ## Tests
75
82
 
76
- - `src/channel/schedule.test.ts` — dispatcher behavior in both branches.
83
+ - `src/channel/schedule.test.ts` — dispatcher behavior for both handler and
84
+ markdown branches plus the `waitUntil` accumulator.
77
85
  - `src/discover/agent.integration.test.ts` — discovery shapes including
78
86
  recursive nesting, markdown form, .md/.ts collision, legacy migration
79
87
  diagnostic, and unsupported leaves.
@@ -83,6 +91,9 @@ event name plus a baked-in artifacts config.
83
91
  — runtime resolution and Nitro task registration with mixed module/markdown
84
92
  schedules.
85
93
  - `test/scenarios/compile-agent.scenario.test.ts` — end-to-end compile with
86
- module + markdown schedules including the no-channel form.
94
+ module + markdown schedules in both forms.
95
+ - `test/scenarios/schedule-trigger.scenario.test.ts` — markdown schedule
96
+ end-to-end through the real dispatcher, including the SCHEDULE_ADAPTER
97
+ step-boundary rehydration.
87
98
  - `test/scenarios/runtime-loaders.scenario.test.ts` — runtime artifact
88
99
  loaders round-trip.
@@ -253,6 +253,77 @@ resolver when the allowed phone numbers come from dynamic state. Use `allowFrom:
253
253
 
254
254
  See [Twilio channel setup](./twilio.md) for webhook URLs, environment variables, and overrides.
255
255
 
256
+ ## Cross-Channel Hand-off
257
+
258
+ Route handlers can start a session on a different channel via `args.receive(channel, ...)`.
259
+ Use this when an inbound request on one channel should pivot the conversation onto another
260
+ -- for example, an incident webhook hits an HTTP route and the operator interacts in Slack.
261
+
262
+ ```ts
263
+ import { defineChannel, POST } from "experimental-ash/channels";
264
+ import slack from "./slack.js";
265
+
266
+ export default defineChannel({
267
+ routes: [
268
+ POST("/incident", async (req, args) => {
269
+ const incident = await req.json();
270
+ args.waitUntil(
271
+ args.receive(slack, {
272
+ message: `Investigate ${incident.reference}: ${incident.title}`,
273
+ args: { channelId: "C0123ABC" },
274
+ auth: {
275
+ authenticator: "incidentio",
276
+ principalType: "service",
277
+ principalId: incident.actor.id,
278
+ attributes: { reference: incident.reference, severity: incident.severity },
279
+ },
280
+ }),
281
+ );
282
+ return new Response("ok");
283
+ }),
284
+ ],
285
+ });
286
+ ```
287
+
288
+ Semantics:
289
+
290
+ - The target channel's authored `receive(input, { send })` hook owns the continuation-token
291
+ format and the initial state. Callers supply only `{ message, args, auth }`.
292
+ - `auth` flows through to `session.initiatorAuth` so the target's event handlers and the
293
+ agent's tools can read who started the session.
294
+ - Calling `args.receive(...)` does **not** also start a session on the current channel. The
295
+ inbound channel's response is whatever the route handler returns explicitly (e.g.
296
+ `200 OK` to acknowledge the webhook).
297
+ - The first argument is the target channel module's default export -- import it directly
298
+ from `agent/channels/<name>.ts`. Identity is matched by reference.
299
+
300
+ Slack's `receive` accepts an optional `initialPost` so the target channel can post a card
301
+ before the agent runs; subsequent agent turns thread under that card.
302
+
303
+ ```ts
304
+ import { Card, CardText } from "experimental-ash/channels/slack";
305
+
306
+ await args.receive(slack, {
307
+ message: "Begin investigation",
308
+ args: {
309
+ channelId: "C0123ABC",
310
+ initialPost: {
311
+ card: Card({ children: [CardText("Investigation Thread for INC-42")] }),
312
+ fallbackText: "Investigation Thread for INC-42",
313
+ },
314
+ },
315
+ auth,
316
+ });
317
+ ```
318
+
319
+ `threadTs` and `initialPost` are mutually exclusive: pass `threadTs` to join an existing
320
+ thread, or `initialPost` to anchor a new one.
321
+
322
+ The card's `ts` becomes the session's continuation token, so any later `@mention` reply
323
+ inside that thread resumes the same session instead of starting a new one. Without
324
+ `initialPost`, programmatically-started Slack sessions have no thread to anchor under and
325
+ follow-up mentions land in a fresh session.
326
+
256
327
  ## File Uploads
257
328
 
258
329
  `send()` accepts `string | UserContent`. To include file attachments,
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  title: "Schedules"
3
- description: "Define recurring cron jobs that run an agent and optionally deliver the result through a channel."
3
+ description: "Define recurring cron jobs that run an agent and optionally hand off to a channel."
4
4
  ---
5
5
 
6
- Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence. The agent can either deliver its output through a channel (e.g. Slack) or run fire-and-forget while calling tools, hitting backends, or writing logs.
6
+ Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence. Each schedule is either a fire-and-forget markdown prompt or a TypeScript handler that decides what to do (typically by handing the work off to a channel).
7
7
 
8
8
  Schedules live under `schedules/` at the root agent package.
9
9
 
@@ -11,27 +11,77 @@ Schedules live under `schedules/` at the root agent package.
11
11
 
12
12
  - schedules are root-only — local subagents cannot declare `schedules/`
13
13
  - a schedule is a single file: `<name>.ts` or `<name>.md`
14
- - the markdown form does not support targeting a channel — use the `.ts` form for that
15
- - the channel argument is **optional**
14
+ - every schedule provides exactly one of `markdown` (fire-and-forget) or `run` (handler)
15
+ - markdown-form (`.md`) schedules cannot declare a handler — use `.ts` for that
16
16
 
17
17
  ## Authoring shapes
18
18
 
19
- ### TypeScript
19
+ ### TypeScript handler (`run`)
20
+
21
+ Use a handler when the schedule should deliver to a channel, branch on conditions, or compute args at fire time. The handler receives a tight subset of `RouteHandlerArgs`:
20
22
 
21
23
  ```ts
22
24
  // agent/schedules/daily-digest.ts
23
- import { defineSchedule, receive } from "experimental-ash/schedules";
25
+ import { defineSchedule } from "experimental-ash/schedules";
24
26
 
25
27
  import slack from "../channels/slack.js";
26
28
 
27
29
  export default defineSchedule({
28
30
  cron: "0 9 * * 1-5",
29
- markdown: "Summarize yesterday's activity and post the digest.",
30
- channel: receive(slack, { channelId: "C0123ABC" }),
31
+ async run({ receive, waitUntil, appAuth }) {
32
+ waitUntil(
33
+ receive(slack, {
34
+ message: "Summarize yesterday's activity and post the digest.",
35
+ args: { channelId: "C0123ABC" },
36
+ auth: appAuth,
37
+ }),
38
+ );
39
+ },
40
+ });
41
+ ```
42
+
43
+ `ScheduleHandlerArgs`:
44
+
45
+ - `receive(channel, { message, args, auth })` — same contract as a route handler's [`args.receive`](./channels/README.md#cross-channel-hand-off). Hands the work off to a channel's authored `receive` hook.
46
+ - `waitUntil(promise)` — extends the cron task's lifetime past handler return so in-flight work settles before the Nitro task completes.
47
+ - `appAuth` — pre-built APP auth context (`{ authenticator: "app", principalId: "ash:app", principalType: "runtime" }`). Pass to `receive(..., { auth: appAuth })` for schedules that run on behalf of the agent itself.
48
+
49
+ #### Announce-then-deliver
50
+
51
+ A handler that hands off to Slack can post an anchor card before the agent runs so the thread shows "what this is about" up front; the agent's reply then lands threaded under that card. Same `initialPost` field [Slack's `receive`](./channels/README.md#cross-channel-hand-off) accepts from a route handler:
52
+
53
+ ```ts
54
+ // agent/schedules/deploy-digest.ts
55
+ import { defineSchedule } from "experimental-ash/schedules";
56
+ import { Card, CardText } from "experimental-ash/channels/slack";
57
+
58
+ import slack from "../channels/slack.js";
59
+
60
+ export default defineSchedule({
61
+ cron: "0 17 * * 1-5",
62
+ async run({ receive, waitUntil, appAuth }) {
63
+ waitUntil(
64
+ receive(slack, {
65
+ message: "Summarize today's production deploys.",
66
+ args: {
67
+ channelId: "C0123ABC",
68
+ initialPost: {
69
+ card: Card({ children: [CardText("Daily Deploy Digest")] }),
70
+ fallbackText: "Daily Deploy Digest",
71
+ },
72
+ },
73
+ auth: appAuth,
74
+ }),
75
+ );
76
+ },
31
77
  });
32
78
  ```
33
79
 
34
- Without a channel the agent runs and the output is discarded:
80
+ `threadTs` and `initialPost` are mutually exclusive on Slack receive args. The anchor card's `ts` becomes the session's continuation token, so any later `@mention` reply in that thread resumes the same session — a digest that sparks a question can be answered in-thread without spinning up a new session.
81
+
82
+ ### Markdown (`markdown`)
83
+
84
+ Fire-and-forget: the agent runs the prompt and the output is discarded. The agent is still free to call tools, write to backends, log, etc.
35
85
 
36
86
  ```ts
37
87
  // agent/schedules/heartbeat.ts
@@ -43,7 +93,7 @@ export default defineSchedule({
43
93
  });
44
94
  ```
45
95
 
46
- ### Markdown
96
+ Markdown frontmatter form:
47
97
 
48
98
  ```md
49
99
  ## <!-- agent/schedules/cleanup.md -->
@@ -53,7 +103,7 @@ export default defineSchedule({
53
103
  Sweep stale workflow state.
54
104
  ```
55
105
 
56
- The body is the prompt. The frontmatter accepts `cron` only — markdown-form schedules cannot reference a channel.
106
+ The body is the prompt. The frontmatter accepts `cron` only.
57
107
 
58
108
  ## Recursive nesting
59
109
 
@@ -73,62 +123,40 @@ agent/schedules/
73
123
  ```ts
74
124
  interface ScheduleDefinition {
75
125
  cron: string;
76
- markdown: string;
77
- channel?: ChannelReceiveRef<unknown>;
126
+ markdown?: string;
127
+ run?: (args: ScheduleHandlerArgs) => Promise<void> | void;
78
128
  }
79
- ```
80
-
81
- - `cron` (required) — a cron expression for when the schedule fires
82
- - `markdown` (required) — the prompt the agent receives when the schedule runs
83
- - `channel` (optional) — a typed channel reference produced by `receive(channel, args)`
84
-
85
- The `markdown` field is the prompt body — the same convention used by `defineInstructions({ markdown })` and `defineSkill({ markdown })`.
86
-
87
- ## Channel targeting
88
-
89
- When a schedule should deliver its output through a channel, use the `receive(channel, args)` helper from `experimental-ash/schedules`:
90
-
91
- ```ts
92
- import { receive } from "experimental-ash/schedules";
93
- import slack from "../channels/slack.js";
94
129
 
95
- receive(slack, { channelId: "C0123ABC" });
130
+ interface ScheduleHandlerArgs {
131
+ receive: CrossChannelReceiveFn;
132
+ waitUntil: (task: Promise<unknown>) => void;
133
+ appAuth: SessionAuthContext;
134
+ }
96
135
  ```
97
136
 
98
- The `args` object's shape is inferred from the channel's typed receive route Slack requires `channelId`; other channels define their own args shape. At compile time, Ash validates that the channel reference resolves to an authored channel under `agent/channels/` whose route implements a `receive()` method.
137
+ Exactly one of `markdown` or `run` must be provided. `defineSchedule` is a pass-through that exists to attach TypeScript types it does not validate at authoring time; the compiler enforces the exclusivity rule.
99
138
 
100
139
  ## Runtime behavior
101
140
 
102
141
  When a schedule fires, the dispatcher takes one of two paths:
103
142
 
104
- ### With a channel
143
+ ### Handler form (`run`)
105
144
 
106
- 1. The dispatcher resolves the channel route by name.
107
- 2. It calls `route.receive()` with the schedule's `markdown` and the channel-specific `args`.
108
- 3. The channel builds its adapter, starts a session, and handles output delivery the same way it handles webhook-initiated messages.
145
+ 1. The dispatcher loads the schedule's compiled module and reads `definition.run`.
146
+ 2. It builds `ScheduleHandlerArgs` against the request-scoped channel bundle and invokes `run(args)`.
147
+ 3. The handler decides everything: which channels to target, what auth to use, whether to short-circuit on holidays, etc.
148
+ 4. Any promises the handler passes to `waitUntil` are awaited before the Nitro task settles.
109
149
 
110
- ### Without a channel
150
+ ### Markdown form
111
151
 
112
- 1. The dispatcher starts a session through the runtime directly with a minimal scheduled-task adapter.
152
+ 1. The dispatcher starts a session through the runtime directly with the framework-owned `SCHEDULE_ADAPTER`.
113
153
  2. The agent runs in **task mode** with the app principal.
114
- 3. Output is discarded — but the agent is still free to call tools, write to backends, log, etc.
154
+ 3. Output is discarded.
115
155
 
116
156
  In both cases, the schedule session uses the **app principal**: `getSession().auth.current` and `getSession().auth.initiator` are set to `"ash:app"` with `principalType: "runtime"`.
117
157
 
118
158
  The session goes through the same durable runtime engine as any other Ash session.
119
159
 
120
- ## Migrating from the legacy directory form
121
-
122
- The directory form (`schedules/<name>/schedule.ts` + `schedules/<name>/prompt.md`) is no longer supported. To migrate, collapse each directory into a single file:
123
-
124
- ```text
125
- agent/schedules/daily-digest/schedule.ts → agent/schedules/daily-digest.ts
126
- agent/schedules/daily-digest/prompt.md → inlined as `markdown:` on
127
- the `defineSchedule({...})` call
128
- ```
129
-
130
- Discovery emits a clear migration diagnostic when it sees a legacy directory layout.
131
-
132
160
  ## When to use a schedule
133
161
 
134
162
  Use a schedule when the agent should initiate work on its own cadence:
@@ -0,0 +1,61 @@
1
+ import type { UserContent } from "ai";
2
+ import type { ChannelAdapter } from "#channel/adapter.js";
3
+ import type { CompiledChannel } from "#channel/compiled-channel.js";
4
+ import type { InferReceiveArgs } from "#channel/receive-args.js";
5
+ import type { Session } from "#channel/session.js";
6
+ import type { Runtime, SessionAuthContext } from "#channel/types.js";
7
+ /**
8
+ * Options accepted by {@link CrossChannelReceiveFn}. Mirrors the input
9
+ * argument of a channel's authored `receive(input, { send })` hook —
10
+ * the runtime constructs `send` internally so route-handler callers
11
+ * only supply the platform args, payload, and auth.
12
+ */
13
+ export interface CrossChannelReceiveOptions<TArgs = Record<string, unknown>> {
14
+ readonly message: string | UserContent;
15
+ readonly args: TArgs;
16
+ readonly auth: SessionAuthContext | null;
17
+ }
18
+ /**
19
+ * Starts a session on a different channel from inside a route handler.
20
+ * The target channel's authored `receive` hook owns continuation-token
21
+ * format and initial state; `auth` is forwarded verbatim and becomes
22
+ * `session.initiatorAuth`.
23
+ */
24
+ export type CrossChannelReceiveFn = <TChannel>(channel: TChannel, options: CrossChannelReceiveOptions<InferReceiveArgs<TChannel>>) => Promise<Session>;
25
+ /**
26
+ * Channel record consumed by the receiver — keeps the public-facing
27
+ * `definition` reference so callers can identify a target by value
28
+ * (the same module-default they imported in their route file).
29
+ */
30
+ export interface CrossChannelTarget {
31
+ readonly name: string;
32
+ readonly definition?: CompiledChannel;
33
+ readonly receive?: CompiledChannel["receive"];
34
+ readonly adapter?: ChannelAdapter;
35
+ }
36
+ /**
37
+ * Builds the `args.receive` closure used by every route handler. The
38
+ * closure resolves the target channel by reference identity against
39
+ * the request-scoped channel bundle, then delegates to the target's
40
+ * authored `receive` hook with a per-target `send` factory.
41
+ */
42
+ export declare function createCrossChannelReceiveFn(runtime: Runtime, channels: readonly CrossChannelTarget[]): CrossChannelReceiveFn;
43
+ interface InvokeChannelReceiveInput {
44
+ readonly runtime: Runtime;
45
+ readonly target: Pick<CrossChannelTarget, "name" | "receive" | "adapter">;
46
+ readonly input: {
47
+ readonly message: string;
48
+ readonly args: Readonly<Record<string, unknown>>;
49
+ readonly auth: SessionAuthContext | null;
50
+ };
51
+ readonly describeMissingReceive: () => string;
52
+ readonly describeMissingAdapter: () => string;
53
+ }
54
+ /**
55
+ * Shared `receive(input, { send })` invocation used by both the route-
56
+ * handler cross-channel surface and the schedule dispatcher. Owns the
57
+ * receive/adapter precondition checks and the per-target `send`
58
+ * factory so both call sites stay byte-identical.
59
+ */
60
+ export declare function invokeChannelReceive(args: InvokeChannelReceiveInput): Promise<Session>;
61
+ export {};
@@ -0,0 +1,50 @@
1
+ import { createSendFn } from "#channel/send.js";
2
+ /**
3
+ * Builds the `args.receive` closure used by every route handler. The
4
+ * closure resolves the target channel by reference identity against
5
+ * the request-scoped channel bundle, then delegates to the target's
6
+ * authored `receive` hook with a per-target `send` factory.
7
+ */
8
+ export function createCrossChannelReceiveFn(runtime, channels) {
9
+ return async (channel, options) => {
10
+ const target = resolveTargetByReference(channel, channels);
11
+ return await invokeChannelReceive({
12
+ runtime,
13
+ target,
14
+ input: {
15
+ message: options.message,
16
+ args: (options.args ?? {}),
17
+ auth: options.auth,
18
+ },
19
+ describeMissingReceive: () => `args.receive(): channel "${target.name}" does not implement receive(). ` +
20
+ `Declare a receive hook on the channel to accept cross-channel sessions.`,
21
+ describeMissingAdapter: () => `args.receive(): channel "${target.name}" has no adapter — cannot build send().`,
22
+ });
23
+ };
24
+ }
25
+ /**
26
+ * Shared `receive(input, { send })` invocation used by both the route-
27
+ * handler cross-channel surface and the schedule dispatcher. Owns the
28
+ * receive/adapter precondition checks and the per-target `send`
29
+ * factory so both call sites stay byte-identical.
30
+ */
31
+ export async function invokeChannelReceive(args) {
32
+ if (!args.target.receive) {
33
+ throw new Error(args.describeMissingReceive());
34
+ }
35
+ if (!args.target.adapter) {
36
+ throw new Error(args.describeMissingAdapter());
37
+ }
38
+ const send = createSendFn(args.runtime, args.target.adapter, args.target.name);
39
+ return await args.target.receive(args.input, { send });
40
+ }
41
+ function resolveTargetByReference(ref, channels) {
42
+ for (const channel of channels) {
43
+ if (channel.definition !== undefined && channel.definition === ref) {
44
+ return channel;
45
+ }
46
+ }
47
+ throw new Error("args.receive(): the channel passed as the first argument is not registered " +
48
+ "in this agent's channels/. Import the channel module's default export from " +
49
+ "agent/channels/<name>.ts and pass that value.");
50
+ }
@@ -0,0 +1,17 @@
1
+ declare const receiveArgsMarker: unique symbol;
2
+ /**
3
+ * Structural marker attached by channel factories (e.g. `slackChannel`,
4
+ * `twilioChannel`) to advertise the args type their `receive()` accepts.
5
+ * `receive(channel, args)` helpers and the route-handler
6
+ * `args.receive(channel, ...)` use this marker to infer typed args
7
+ * from a plain channel import.
8
+ */
9
+ export interface TypedReceiveRoute<TArgs = Record<string, unknown>> {
10
+ readonly [receiveArgsMarker]?: TArgs;
11
+ }
12
+ /**
13
+ * Extracts the receive-args type from a channel value, falling back to
14
+ * `Record<string, unknown>` when the channel does not advertise one.
15
+ */
16
+ export type InferReceiveArgs<TChannel> = TChannel extends TypedReceiveRoute<infer TArgs> ? TArgs : Record<string, unknown>;
17
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,10 +1,19 @@
1
1
  import type { UserContent } from "ai";
2
+ import type { CrossChannelReceiveFn } from "#channel/cross-channel-receive.js";
2
3
  import type { SessionAuthContext } from "#channel/types.js";
3
4
  import type { InputResponse } from "#runtime/input/types.js";
4
5
  import type { Session } from "#channel/session.js";
5
6
  export interface RouteHandlerArgs<TState = undefined> {
6
7
  send: SendFn<TState>;
7
8
  getSession: GetSessionFn;
9
+ /**
10
+ * Starts a session on a different channel. Use to hand off inbound
11
+ * work — e.g. an HTTP webhook receives a notification and pivots the
12
+ * conversation onto Slack. The target's authored `receive` hook owns
13
+ * continuation-token format and initial state; the caller supplies
14
+ * the payload, channel-specific args, and auth.
15
+ */
16
+ receive: CrossChannelReceiveFn;
8
17
  params: Readonly<Record<string, string>>;
9
18
  waitUntil: (task: Promise<unknown>) => void;
10
19
  requestIp: string | null;