experimental-ash 0.60.0 → 0.61.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 (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/docs/public/advanced/auth-and-route-protection.mdx +8 -6
  3. package/dist/docs/public/agent-ts.md +17 -47
  4. package/dist/docs/public/channels/README.md +60 -0
  5. package/dist/docs/public/channels/ash.mdx +103 -0
  6. package/dist/docs/public/channels/custom.mdx +288 -0
  7. package/dist/docs/public/channels/discord.mdx +1 -3
  8. package/dist/docs/public/channels/github.md +1 -1
  9. package/dist/docs/public/channels/meta.json +3 -0
  10. package/dist/docs/public/channels/slack.mdx +29 -1
  11. package/dist/docs/public/channels/teams.mdx +1 -3
  12. package/dist/docs/public/channels/telegram.mdx +1 -3
  13. package/dist/docs/public/channels/twilio.mdx +1 -3
  14. package/dist/docs/public/frontend/nextjs.md +24 -8
  15. package/dist/docs/public/onboarding.md +4 -3
  16. package/dist/docs/public/schedules.mdx +4 -4
  17. package/dist/docs/public/tools.mdx +1 -1
  18. package/dist/src/channel/compiled-channel.d.ts +2 -6
  19. package/dist/src/channel/routes.d.ts +75 -7
  20. package/dist/src/channel/routes.js +1 -1
  21. package/dist/src/compiler/manifest.d.ts +4 -4
  22. package/dist/src/compiler/manifest.js +1 -1
  23. package/dist/src/internal/application/package.js +1 -1
  24. package/dist/src/internal/nitro/host/channel-routes.d.ts +2 -2
  25. package/dist/src/internal/nitro/host/channel-routes.js +2 -1
  26. package/dist/src/internal/nitro/host/create-application-nitro.js +1 -1
  27. package/dist/src/internal/nitro/routes/channel-dispatch.d.ts +2 -0
  28. package/dist/src/internal/nitro/routes/channel-dispatch.js +1 -1
  29. package/dist/src/internal/vercel-agent-summary.d.ts +2 -2
  30. package/dist/src/internal/vercel-agent-summary.js +1 -1
  31. package/dist/src/packages/ash-scaffold/src/channels.js +1 -1
  32. package/dist/src/public/channels/index.d.ts +1 -1
  33. package/dist/src/public/channels/index.js +1 -1
  34. package/dist/src/public/definitions/channel.d.ts +7 -0
  35. package/dist/src/public/definitions/defineChannel.d.ts +3 -6
  36. package/dist/src/public/definitions/defineChannel.js +1 -1
  37. package/dist/src/runtime/framework-channels/index.js +1 -1
  38. package/dist/src/runtime/resolve-channel.js +1 -1
  39. package/dist/src/runtime/types.d.ts +8 -3
  40. package/package.json +1 -1
  41. package/dist/docs/public/channels/attachments.md +0 -71
  42. package/dist/docs/public/channels/index.md +0 -661
@@ -1,661 +0,0 @@
1
- ---
2
- title: "Channels"
3
- description: "Deliver your agent over HTTP, Slack, GitHub, Discord, Twilio, Telegram, Microsoft Teams, and custom transports."
4
- url: /channels
5
- ---
6
-
7
- Channels let Ash receive messages from external systems and deliver responses back to the same
8
- conversation surface.
9
-
10
- A channel is defined with `defineChannel()`. It declares routes, optional state, an optional
11
- `context()` factory, and typed event handlers under `events`. Route handlers receive the inbound
12
- request and a helpers object with `send()` and `getSession()`. The `send()` function is the unified
13
- entry point for starting a new turn or resuming a session -- it replaces the old `ctx.agent.run()` and
14
- `ctx.agent.deliver()` split.
15
-
16
- The runtime and harness still own the model turn, tool execution, compaction, and session
17
- persistence.
18
-
19
- ## Where Channels Fit
20
-
21
- Ash ships the public HTTP protocol as channels:
22
-
23
- - `ashChannel({ auth })` for the built-in Ash protocol channel
24
-
25
- Ash also supports authored channels under `agent/channels/` for platform-specific integrations such
26
- as Slack, GitHub, Discord, Twilio, Telegram, Microsoft Teams, or custom webhooks.
27
-
28
- ## Filesystem Shape
29
-
30
- ```text
31
- agent/
32
- agent.ts
33
- instructions.md
34
- channels/
35
- slack.ts
36
- ```
37
-
38
- Rules that matter:
39
-
40
- - `channels/` is root-only today.
41
- - local subagents do not declare channels.
42
- - channel files are module-backed only.
43
- - the file stem becomes the channel id.
44
-
45
- ## The Main API
46
-
47
- ```ts
48
- import { defineChannel, POST, GET } from "experimental-ash/channels";
49
-
50
- export default defineChannel({
51
- routes: [
52
- POST("/message", async (req, { send, getSession, waitUntil }) => {
53
- const body = await req.json();
54
- const session = await send(body.message, {
55
- auth: null,
56
- continuationToken: body.token,
57
- });
58
- return Response.json({ sessionId: session.id });
59
- }),
60
- GET("/sessions/:sessionId/stream", async (req, { getSession, params }) => {
61
- const session = getSession(params.sessionId);
62
- const stream = await session.getEventStream();
63
- return new Response(stream, {
64
- headers: { "content-type": "text/event-stream" },
65
- });
66
- }),
67
- ],
68
- events: {
69
- "message.completed"(event, ctx) {
70
- // handle completed messages
71
- },
72
- },
73
- });
74
- ```
75
-
76
- `defineChannel({ routes, ... })` defines a channel. Routes are declared with `POST()` and `GET()`
77
- helpers. Each route handler receives the raw `Request` and a helpers object:
78
-
79
- - `send(message, { auth, continuationToken, state? })` -- start or resume a session. Returns a
80
- `Session`.
81
- - `getSession(sessionId)` -- look up an existing session. The returned `Session` exposes
82
- `getEventStream({ startIndex? })` for streaming.
83
- - `params` -- route parameters extracted from the path pattern.
84
- - `waitUntil(promise)` -- extend the request lifetime for background work.
85
-
86
- Event handlers like `"message.completed"` are declared under the `events` key on the config object.
87
- They receive `(eventData, channel, ctx)` where `channel` carries platform handles and session
88
- continuation operations, and `ctx` is the Ash `SessionContext`. Use
89
- `toolResultFrom` from `experimental-ash/tools` to narrow tool results (see
90
- [Hooks — Narrowing tool results](../hooks.mdx#narrowing-tool-results)).
91
-
92
- ## The Ash Channel
93
-
94
- The framework ships its canonical session protocol on `experimental-ash/channels/ash`:
95
-
96
- - `ashChannel({ auth, onMessage?, events? })`
97
-
98
- This is the channel the Ash dev client and any deployed-agent SDK talk to — it mounts the routes
99
- under `/ash/v1/session*` documented in [Ash External Agent Protocol](../../external-agent-protocol.md).
100
-
101
- `auth` accepts either a single `AuthFn` or an ordered array walked in order: the first entry
102
- returning a `SessionAuthContext` wins, `null` / `undefined` skips, exhaustion (including `[]`)
103
- returns `401`. Use `experimental-ash/channels/auth` for the common helpers such as `localDev()`,
104
- `vercelOidc()`, `httpBasic()`, and `none()`.
105
-
106
- ```ts
107
- import { ashChannel } from "experimental-ash/channels/ash";
108
- import { localDev, vercelOidc } from "experimental-ash/channels/auth";
109
-
110
- export default ashChannel({
111
- auth: [localDev(), vercelOidc()],
112
- });
113
- ```
114
-
115
- Pass `onMessage` to add authenticated context before the Ash HTTP channel dispatches a user
116
- message. The hook runs after `auth` succeeds and body parsing completes, so `ctx.ash.caller` is
117
- available for caller-specific lookups. Return `{ auth, context }` to choose the runtime session
118
- auth and append context strings as user messages before the inbound message, or `null` to accept
119
- the request without dispatching a turn. When `onMessage` is omitted, Ash dispatches with
120
- `defaultAshAuth(ctx)`.
121
-
122
- ```ts
123
- import { ashChannel, defaultAshAuth } from "experimental-ash/channels/ash";
124
-
125
- export default ashChannel({
126
- auth: [localDev(), vercelOidc()],
127
- async onMessage(ctx, message) {
128
- const auth = defaultAshAuth(ctx);
129
- const profile = auth ? await loadCallerProfile(auth.principalId) : null;
130
-
131
- return {
132
- auth,
133
- context: profile ? [`Caller profile:\n${JSON.stringify(profile)}`] : [],
134
- };
135
- },
136
- });
137
- ```
138
-
139
- Pass `events` to observe runtime stream events on the default HTTP channel. The Ash channel has no
140
- platform-specific context, so `channel` exposes session continuation operations and `ctx` exposes
141
- the current session context:
142
-
143
- ```ts
144
- export default ashChannel({
145
- auth: [localDev(), vercelOidc()],
146
- events: {
147
- "turn.started"(_event, _channel, ctx) {
148
- auditTurn(ctx.session.id);
149
- },
150
- },
151
- });
152
- ```
153
-
154
- `pnpm create experimental-ash-agent` scaffolds the Web Chat example channel at
155
- `agent/channels/ash.ts`. It permits Vercel OIDC and localhost requests and includes an
156
- `exampleProductionAuth()` placeholder that throws in production until you replace it
157
- with end-user auth. If you delete the authored file, Ash falls back to
158
- `[localDev(), vercelOidc()]`; that default does not admit browser users in production.
159
- See [Auth and Route Protection](../auth-and-route-protection.mdx) for the full walking
160
- semantics and helper reference.
161
-
162
- ## Slack Channels
163
-
164
- Slack channels are authored with a single `slackChannel()` factory. Pass no config for the
165
- zero-config defaults, or supply only the fields you want to override -- everything else keeps the
166
- default.
167
-
168
- ### Zero-config
169
-
170
- ```ts
171
- import { slackChannel } from "experimental-ash/channels/slack";
172
-
173
- export default slackChannel();
174
- ```
175
-
176
- Handles app mentions, direct messages, typing indicators, message delivery, and HITL interactions
177
- out of the box. The default `onAppMention` and `onDirectMessage` derive auth from the inbound Slack
178
- actor and post a `"Thinking…"` typing indicator before the workflow runtime starts.
179
-
180
- ### Custom config
181
-
182
- When you need custom rendering or event handling, pass a config object. `onAppMention` decides
183
- whether to dispatch a channel mention; `onDirectMessage` does the same for 1:1 DMs (`message`
184
- events with `channel_type: "im"`); `events` lets you replace individual event handlers;
185
- `onInteraction` handles non-HITL button clicks.
186
-
187
- ```ts
188
- import { slackChannel } from "experimental-ash/channels/slack";
189
-
190
- export default slackChannel({
191
- onAppMention(ctx, message) {
192
- if (!message.author) return null;
193
- return {
194
- auth: {
195
- principalId: message.author.userId,
196
- principalType: "user",
197
- authenticator: "slack",
198
- attributes: {},
199
- },
200
- };
201
- },
202
- onDirectMessage(ctx, message) {
203
- if (!message.author) return null;
204
- return {
205
- auth: {
206
- principalId: message.author.userId,
207
- principalType: "user",
208
- authenticator: "slack",
209
- attributes: { surface: "im" },
210
- },
211
- };
212
- },
213
- events: {
214
- "actions.requested"(event, ctx) {
215
- const labels = event.actions.map((a) => (a.kind === "tool-call" ? a.toolName : a.kind));
216
- ctx.thread.startTyping(`Running ${labels.join(", ")}...`);
217
- },
218
- "message.completed"(event, ctx) {
219
- if (event.finishReason === "tool-calls") return;
220
- if (event.message) ctx.thread.post(event.message);
221
- },
222
- "session.failed"(_event, ctx) {
223
- ctx.thread.post("Something went wrong.");
224
- },
225
- },
226
- });
227
- ```
228
-
229
- Fields you supply fully replace the corresponding default -- if you pass your own
230
- `"message.completed"` handler, the default's behavior for that event is gone. Other defaults stay
231
- in place. Direct messages require the Slack app to subscribe to `message.im` with the `im:history`
232
- scope.
233
-
234
- ### Interactions
235
-
236
- Interactions (button clicks, modals) are platform events, not agent events. They do not appear in the
237
- event handlers.
238
-
239
- `slackChannel()` handles interactions in two paths:
240
-
241
- - **HITL interactions** (approval buttons matching pending input requests) are delivered to the agent
242
- automatically. The agent resumes.
243
- - **Custom interactions** (feedback buttons, SQL toggles) are passed to `onInteraction`:
244
-
245
- ```ts
246
- import { slackChannel } from "experimental-ash/channels/slack";
247
-
248
- export default slackChannel({
249
- onAppMention(ctx, message) {
250
- if (!message.author) return null;
251
- return {
252
- auth: {
253
- principalId: message.author.userId,
254
- principalType: "user",
255
- authenticator: "slack",
256
- attributes: {},
257
- },
258
- };
259
- },
260
- events: {
261
- "message.completed"(event, ctx) {
262
- if (event.finishReason === "tool-calls") return;
263
- if (event.message) ctx.thread.post(event.message);
264
- },
265
- },
266
- onInteraction(action, ctx) {
267
- // handle custom button clicks
268
- },
269
- });
270
- ```
271
-
272
- ### The two shapes
273
-
274
- ```text
275
- defineChannel({ routes: [...], events: {...} }) -- raw: you handle HTTP
276
- ashChannel({ auth, onMessage?, events? }) -- ash: default HTTP protocol
277
- slackChannel({ onAppMention?, onDirectMessage?, events?, onInteraction? }) -- slack: everything wired, override as needed
278
- ```
279
-
280
- `slackChannel(config)` compiles down to `defineChannel` plus typed inbound parsing and event
281
- dispatch for app mentions, direct messages, and interactions.
282
-
283
- For a Slack app backed by Vercel Connect, see [Slack channel setup](./slack.mdx) to create the Connect client
284
- and channel file.
285
-
286
- ## GitHub Channels
287
-
288
- GitHub channels are authored with `githubChannel()`:
289
-
290
- ```ts
291
- import { githubChannel } from "experimental-ash/channels/github";
292
-
293
- export default githubChannel({
294
- botName: "my-agent",
295
- });
296
- ```
297
-
298
- The channel verifies GitHub App webhooks at `/ash/v1/github`, dispatches bot-directed issue/PR
299
- comments and pull-request review comments, can opt into issue and pull-request hooks, exposes
300
- GitHub-native issue, PR, reaction, and checkout helpers, and injects bounded PR metadata,
301
- file lists, and patch text as turn `context`.
302
-
303
- See [GitHub channel setup](./github.md) for GitHub App permissions, PR context, checkout, and
304
- custom inbound hooks.
305
-
306
- ## Discord Channels
307
-
308
- Discord channels are authored with `discordChannel()`:
309
-
310
- ```ts
311
- import { discordChannel } from "experimental-ash/channels/discord";
312
-
313
- export default discordChannel();
314
- ```
315
-
316
- The channel verifies Discord's Ed25519 interaction signature headers, accepts HTTP Interactions at
317
- `/ash/v1/discord`, responds to `PING`, dispatches application commands, and resolves HITL button,
318
- select, and modal submissions back into Ash input responses. The default command parser uses a
319
- string option named `message` as the agent prompt and derives auth from the invoking Discord user.
320
-
321
- Default delivery edits the original deferred interaction response for the first agent reply, sends
322
- followups after that, and falls back to bot-token channel messages when the interaction token can no
323
- longer be used. Proactive `receive(discord, { channelId })` sessions also use bot-token channel
324
- messages. Default progress handlers trigger Discord's short-lived typing indicator when bot auth is
325
- available.
326
-
327
- See [Discord channel setup](./discord.mdx) for endpoint setup, environment variables, command
328
- registration, and overrides.
329
-
330
- ## Twilio Channels
331
-
332
- Twilio channels are authored with `twilioChannel()`:
333
-
334
- ```ts
335
- import { twilioChannel } from "experimental-ash/channels/twilio";
336
-
337
- export default twilioChannel({
338
- allowFrom: "+15551234567",
339
- });
340
- ```
341
-
342
- The channel verifies `X-Twilio-Signature` itself, accepts inbound SMS at
343
- `/ash/v1/twilio/messages`, answers phone calls at `/ash/v1/twilio/voice`, and dispatches speech
344
- transcripts posted to `/ash/v1/twilio/voice/transcription`. The raw continuation token is the
345
- caller/sender phone number plus the Twilio receiver (`From:To`), so texts and call transcripts for
346
- the same phone-number pair resume the same Ash session without collapsing conversations that use
347
- different Twilio numbers. `allowFrom` accepts a single phone number, a list, or a zero-argument
348
- resolver when the allowed phone numbers come from dynamic state. Use `allowFrom: "*"` only when the
349
- `onText` and `onVoice` hooks perform their own incoming-number checks.
350
-
351
- See [Twilio channel setup](./twilio.mdx) for webhook URLs, environment variables, and overrides.
352
-
353
- ## Telegram Channels
354
-
355
- Telegram channels are authored with `telegramChannel()`:
356
-
357
- ```ts
358
- import { telegramChannel } from "experimental-ash/channels/telegram";
359
-
360
- export default telegramChannel({
361
- botUsername: "my_bot",
362
- });
363
- ```
364
-
365
- The channel verifies Telegram's `X-Telegram-Bot-Api-Secret-Token` webhook header, accepts updates at
366
- `/ash/v1/telegram`, dispatches private messages and addressed group messages, and resolves HITL
367
- inline-keyboard callbacks and ForceReply answers back into Ash input responses. Private chats use
368
- chat-wide continuation tokens. Group, supergroup, and forum-topic sessions include the chat id,
369
- optional `message_thread_id`, and a conversation anchor so multiple bot conversations in the same
370
- chat do not collapse.
371
-
372
- Default delivery sends plain text through Telegram `sendMessage` with no `parse_mode`, splits text
373
- at Telegram's 4096-character limit, and uses best-effort `sendChatAction("typing")` progress
374
- indicators. Photos and documents are exposed as file parts and fetched through Telegram `getFile`
375
- when the model needs the bytes.
376
-
377
- See [Telegram channel setup](./telegram.mdx) for webhook registration, environment variables, group
378
- privacy notes, HITL behavior, attachments, and proactive sessions.
379
-
380
- ## Microsoft Teams Channels
381
-
382
- Teams channels are authored with `teamsChannel()`:
383
-
384
- ```ts
385
- import { teamsChannel } from "experimental-ash/channels/teams";
386
-
387
- export default teamsChannel();
388
- ```
389
-
390
- The channel verifies Bot Connector bearer JWTs, accepts Teams Bot Framework Activity POSTs at
391
- `/ash/v1/teams`, dispatches personal messages and direct bot mentions, renders HITL prompts as
392
- Adaptive Cards, and sends agent replies through the Bot Framework Connector REST API. Personal chats
393
- resume by conversation id; channel and group-chat threads resume by conversation id plus root
394
- activity id.
395
-
396
- Proactive `receive(teams, { target })` sessions require an existing conversation reference
397
- (`serviceUrl` and `conversationId`). See [Microsoft Teams channel setup](./teams.mdx) for Azure Bot
398
- setup, environment variables, proactive handoff, HITL, and file options.
399
-
400
- ## Cross-Channel Hand-off
401
-
402
- Route handlers can start a session on a different channel via `args.receive(channel, ...)`.
403
- Use this when an inbound request on one channel should pivot the conversation onto another
404
- -- for example, an incident webhook hits an HTTP route and the operator interacts in Slack.
405
-
406
- ```ts
407
- import { defineChannel, POST } from "experimental-ash/channels";
408
- import slack from "./slack.js";
409
-
410
- export default defineChannel({
411
- routes: [
412
- POST("/incident", async (req, args) => {
413
- const incident = await req.json();
414
- args.waitUntil(
415
- args.receive(slack, {
416
- message: `Investigate ${incident.reference}: ${incident.title}`,
417
- target: { channelId: "C0123ABC" },
418
- auth: {
419
- authenticator: "incidentio",
420
- principalType: "service",
421
- principalId: incident.actor.id,
422
- attributes: { reference: incident.reference, severity: incident.severity },
423
- },
424
- }),
425
- );
426
- return new Response("ok");
427
- }),
428
- ],
429
- });
430
- ```
431
-
432
- Semantics:
433
-
434
- - The target channel's authored `receive(input, { send })` hook owns the continuation-token
435
- format and the initial state. Callers supply only `{ message, target, auth }`.
436
- - `auth` flows through to `session.auth.initiator` so the target's event handlers and the
437
- agent's tools can read who started the session.
438
- - Calling `args.receive(...)` does **not** also start a session on the current channel. The
439
- inbound channel's response is whatever the route handler returns explicitly (e.g.
440
- `200 OK` to acknowledge the webhook).
441
- - The first argument is the target channel module's default export -- import it directly
442
- from `agent/channels/<name>.ts`. Identity is matched by reference.
443
-
444
- ### Slack thread anchoring
445
-
446
- When a Slack session starts without a `threadTs` (programmatic `args.receive(slack, ...)`,
447
- schedule fires, etc.), the channel **auto-anchors on the first agent post**: the message
448
- becomes the thread root, and every subsequent post, typing indicator, and inbound
449
- `@mention` reply in that thread resumes the same Ash session. Schedule digests, webhook
450
- hand-offs, and other session-from-nothing flows produce clean Slack threads with no extra
451
- wiring — the channel passes the new channel-local token to
452
- `ctx.session.setContinuationToken(...)`, and the runtime re-keys the parked session to
453
- `slack:<channelId>:<anchored-ts>` so follow-up mentions land back in the same workflow.
454
-
455
- Pass `initialMessage` when you want a structured anchor card to land _before_ the agent
456
- runs — useful for "Investigation Thread for INC-42"-style banners that should precede
457
- the model's first reply:
458
-
459
- ```ts
460
- import { Card, CardText } from "experimental-ash/channels/slack";
461
-
462
- await args.receive(slack, {
463
- message: "Begin investigation",
464
- target: {
465
- channelId: "C0123ABC",
466
- initialMessage: {
467
- card: Card({ children: [CardText("Investigation Thread for INC-42")] }),
468
- fallbackText: "Investigation Thread for INC-42",
469
- },
470
- },
471
- auth,
472
- });
473
- ```
474
-
475
- For inbound mentions in an existing Slack thread, `onAppMention` and
476
- `onDirectMessage` may return `context` to inject thread history as
477
- user messages in session history. See
478
- [Slack thread context](/docs/channels/slack#thread-context).
479
-
480
- `threadTs` and `initialMessage` are mutually exclusive: pass `threadTs` to join an existing
481
- thread, or `initialMessage` to anchor a new one. With neither, the first agent post anchors
482
- the thread automatically.
483
-
484
- ## Channel Metadata
485
-
486
- Channels can project a subset of their adapter state as **metadata** available to
487
- instrumentation resolvers, dynamic tool resolvers, and dynamic skill/instruction resolvers.
488
- Define a `metadata(state)` function on the channel config:
489
-
490
- ```ts
491
- import { defineChannel, POST } from "experimental-ash/channels";
492
-
493
- export default defineChannel({
494
- state: {
495
- topic: null as string | null,
496
- contextMessages: [] as string[],
497
- internalCounter: 0,
498
- },
499
-
500
- metadata(state) {
501
- // Curated projection — internalCounter is withheld.
502
- return {
503
- topic: state.topic,
504
- contextMessages: state.contextMessages,
505
- };
506
- },
507
-
508
- routes: [
509
- POST("/start", async (req, { send }) => {
510
- const body = await req.json();
511
- await send(body.message, {
512
- auth: null,
513
- continuationToken: body.token,
514
- state: { topic: body.topic, contextMessages: body.context, internalCounter: 0 },
515
- });
516
- return new Response("ok");
517
- }),
518
- ],
519
- events: {
520
- "turn.started"(_event, ctx) {
521
- ctx.state.internalCounter += 1;
522
- },
523
- },
524
- });
525
- ```
526
-
527
- The projection is re-evaluated whenever adapter state changes (after channel event handlers
528
- run). Dynamic tool resolvers read it via `ctx.channel.metadata` and narrow it with
529
- `isChannel`. See [Dynamic Tools — Channel Metadata](../tools.mdx#channel-metadata) for the
530
- full consumption pattern.
531
-
532
- ### Subagent propagation
533
-
534
- When a parent agent dispatches a subagent, the framework forwards the parent's channel
535
- metadata projection to the child. The subagent's dynamic tool resolvers see the originating
536
- channel's `kind` and `metadata` — not `kind: "subagent"` with empty metadata. This lets
537
- subagents conditionally resolve tools based on which channel is driving the conversation.
538
-
539
- ### Shared with instrumentation
540
-
541
- The same `metadata(state)` projector serves both dynamic resolvers and the instrumentation
542
- `metadata["step.started"]` resolver. Each consumer picks what it needs: instrumentation
543
- flattens to `Record<string, string>` for OTel span attributes; tool resolvers use the full
544
- structured object.
545
-
546
- ## Continuation Tokens
547
-
548
- Each call to `send(message, { auth, continuationToken, state? })` from a channel route
549
- addresses a session by its **channel-local raw token**. The framework prepends the
550
- channel name (the file stem under `agent/channels/`) before handing the token to the
551
- runtime, so a Slack route that passes `"C0123ABC:1800000000.001234"` ends up addressing
552
- session `"slack:C0123ABC:1800000000.001234"`.
553
-
554
- Authored channels typically ship a small helper for building the token:
555
-
556
- ```ts
557
- import { slackContinuationToken } from "experimental-ash/channels/slack";
558
- import { twilioContinuationToken } from "experimental-ash/channels/twilio";
559
-
560
- slackContinuationToken("C0123ABC", "1800000000.001234"); // "C0123ABC:1800000000.001234"
561
- twilioContinuationToken("+15551234567", "+15557654321"); // "+15551234567:+15557654321"
562
- ```
563
-
564
- Custom channels just write a function that joins their identity fields. The framework
565
- does not derive anything for you — the channel owns its token format.
566
-
567
- ### Re-keying mid-session
568
-
569
- When the identity that should address a session isn't known until the first agent post
570
- (Slack's auto-anchor: the post's `ts` becomes the thread root), the channel re-keys the
571
- parked session by calling `ctx.session.setContinuationToken(...)` from a handler. Pass
572
- the **channel-local raw token**; the runtime preserves the current channel namespace:
573
-
574
- ```ts
575
- import { defineChannel } from "experimental-ash/channels";
576
-
577
- defineChannel<{ ref: string | null }>({
578
- state: { ref: null },
579
- context(state, session) {
580
- return {
581
- state,
582
- registerAnchor(ref: string) {
583
- state.ref = ref;
584
- session.setContinuationToken(ref);
585
- },
586
- };
587
- },
588
- events: {
589
- "message.completed"(_event, ctx) {
590
- if (!ctx.state.ref) ctx.registerAnchor(mintRef());
591
- },
592
- },
593
- routes: [
594
- /* ... */
595
- ],
596
- });
597
- ```
598
-
599
- The workflow runtime disposes the current park hook at the next step boundary and
600
- registers a new one at the new token. Inbound deliveries already addressed to the old
601
- token are dropped — coordinate with your senders so follow-up traffic uses the new
602
- token.
603
-
604
- ## File Uploads
605
-
606
- `send()` accepts `string | UserContent`. To include file attachments,
607
- pass a `UserContent` array mixing text and file parts:
608
-
609
- ```ts
610
- await send(
611
- [
612
- { type: "text", text: body.message },
613
- { type: "file", data: imageBytes, mediaType: "image/png" },
614
- ],
615
- { auth: null, continuationToken: token },
616
- );
617
- ```
618
-
619
- For platforms like Slack where files are behind authenticated URLs,
620
- put `URL` objects in `FilePart.data` and declare `fetchFile` on the
621
- channel config:
622
-
623
- ```ts
624
- defineChannel({
625
- fetchFile(url) {
626
- if (!url.startsWith("https://files.slack.com/")) return null;
627
- return fetch(url, { headers: { authorization: `Bearer ${token}` } })
628
- .then((r) => r.arrayBuffer())
629
- .then((b) => ({ bytes: Buffer.from(b) }));
630
- },
631
-
632
- routes: [
633
- POST("/webhook", async (req, { send }) => {
634
- await send(
635
- [
636
- { type: "text", text: message.text },
637
- ...message.attachments.map((a) => ({
638
- type: "file" as const,
639
- data: new URL(a.url),
640
- mediaType: a.mediaType,
641
- })),
642
- ],
643
- { auth, continuationToken, state },
644
- );
645
- }),
646
- ],
647
- });
648
- ```
649
-
650
- See [Channel file uploads](./attachments.md) for the full guide.
651
-
652
- ## What To Read Next
653
-
654
- - [Slack channel setup](./slack.mdx)
655
- - [GitHub channel setup](./github.md)
656
- - [Telegram channel setup](./telegram.mdx)
657
- - [Channel file uploads](./attachments.md)
658
- - [Project Layout](../project-layout.md)
659
- - [TypeScript API](../typescript-api.md)
660
- - [Auth And Route Protection](../auth-and-route-protection.mdx)
661
- - [Ash External Agent Protocol](../../external-agent-protocol.md)