wolli 0.0.1 → 0.0.2

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.
@@ -0,0 +1,715 @@
1
+ # Integrations
2
+
3
+ Integrations are a wolli-native subsystem: a TypeScript module that models a bidirectional port to the outside world. Where an [extension](./extensions.md) is agent-owned behavior, an integration is a platform-owned transport. It faces the network, holds credentials, authenticates inbound traffic, and turns outside activity into events. It does not decide what the agent should care about — an extension does that.
4
+
5
+ The split is the whole point:
6
+
7
+ - **Integrations make sounds.** A Telegram message arrives. The clock ticks. The integration makes that available as an event and exposes actions to talk back.
8
+ - **Extensions listen.** An extension subscribes to those events, decides which matter, and maps them onto sessions.
9
+
10
+ So an integration is a transport, not a workflow object. The workflow lives in the paired extension, like everything else the agent learns or builds.
11
+
12
+ > **Note:** The integration factory's first argument is named `wolli` throughout this document — by convention, the same as the extension factory argument. It is the `IntegrationsAPI` object; call it whatever you like. The package.json manifest key that declares integrations is `"wolli"`; that key name is fixed and unrelated to the argument name.
13
+ >
14
+ > An integration has two halves. The **producer/transport** half (`run`, `actions`, `events`, `onboard`) is the integration module itself, registered via `wolli.registerIntegration`. The **mapping** half is an ordinary extension that consumes the integration via `wolli.getIntegration(...)`. The two ship together as one package (see [The dual-half package](#the-dual-half-package)).
15
+
16
+ ## Table of Contents
17
+
18
+ - [Quick Start](#quick-start)
19
+ - [Integration Locations](#integration-locations)
20
+ - [Available Imports](#available-imports)
21
+ - [Writing an Integration](#writing-an-integration)
22
+ - [IntegrationConfig](#integrationconfig)
23
+ - [The producer: run(ctx)](#the-producer-runctx)
24
+ - [Durable state: ctx.store](#durable-state-ctxstore)
25
+ - [Actions](#actions)
26
+ - [Onboarding](#onboarding)
27
+ - [Consuming an Integration](#consuming-an-integration)
28
+ - [The dual-half package](#the-dual-half-package)
29
+ - [Installing and configuring](#installing-and-configuring)
30
+ - [Error Handling](#error-handling)
31
+ - [Worked example: Telegram (bidirectional chat)](#worked-example-telegram-bidirectional-chat)
32
+ - [Worked example: Scheduler (timer to wake)](#worked-example-scheduler-timer-to-wake)
33
+
34
+ ## Quick Start
35
+
36
+ An integration is a default-exported factory that registers one or more services. The transport half emits events and exposes actions; the mapping half (an extension) consumes them.
37
+
38
+ `integration.ts` — the transport:
39
+
40
+ ```typescript
41
+ import type { IntegrationsAPI } from "@opsyhq/wolli";
42
+ import { Type } from "typebox";
43
+
44
+ export default function (wolli: IntegrationsAPI) {
45
+ wolli.registerIntegration({
46
+ name: "ticker",
47
+ account: Type.Object({ intervalMs: Type.Optional(Type.Number()) }),
48
+ events: {
49
+ tick: Type.Object({ at: Type.Number() }),
50
+ },
51
+ actions: {
52
+ now: {
53
+ description: "Return the current epoch ms.",
54
+ parameters: Type.Object({}),
55
+ execute: async () => ({ at: Date.now() }),
56
+ },
57
+ },
58
+ run(ctx) {
59
+ const account = ctx.account as { intervalMs?: number };
60
+ const timer = setInterval(() => ctx.emit("tick", { at: Date.now() }), account.intervalMs ?? 1000);
61
+ return () => clearInterval(timer); // disposer; also fired on ctx.signal abort
62
+ },
63
+ });
64
+ }
65
+ ```
66
+
67
+ `mapping.ts` — the extension that listens:
68
+
69
+ ```typescript
70
+ import type { ExtensionAPI } from "@opsyhq/wolli";
71
+
72
+ export default function (wolli: ExtensionAPI) {
73
+ const ticker = wolli.getIntegration("ticker", "default");
74
+ ticker.on("tick", async (data) => {
75
+ const { at } = data as { at: number };
76
+ const tag = { "ticker:default": "1" };
77
+ const [match] = await wolli.findSessions(tag);
78
+ const session = match
79
+ ? await wolli.openSession(match.id)
80
+ : await wolli.createSession({
81
+ setup: async (sessionManager) => {
82
+ await sessionManager.appendTags(tag);
83
+ },
84
+ });
85
+ session.appendEntry("tick", { at }); // durable entry, not sent to the LLM
86
+ });
87
+ }
88
+ ```
89
+
90
+ For a single agent, the two files can live directly in its folders — the transport in `integrations/`, the mapping in `extensions/` — where both are auto-discovered; no package needed. Package them as a [plugin](./plugins.md) (see [The dual-half package](#the-dual-half-package)) only to share, version, or reinstall them across agents:
91
+
92
+ ```bash
93
+ wolli <agent> plugins install ./path/to/ticker
94
+ ```
95
+
96
+ ## Integration Locations
97
+
98
+ Integrations are discovered per-agent, like extensions. They live in the agent's own home; there is no project-local integration location.
99
+
100
+ | Location | Scope |
101
+ |----------|-------|
102
+ | `~/.wolli/agents/<name>/integrations/*.ts` | The agent (all sessions) |
103
+ | `~/.wolli/agents/<name>/integrations/*/index.ts` | The agent (subdirectory) |
104
+
105
+ Plugins installed with `wolli <agent> plugins install <source>` are resolved into this folder by the package manager. Configured account credentials live separately, in the per-agent `integrations.json`:
106
+
107
+ ```
108
+ ~/.wolli/agents/<name>/
109
+ ├── integrations/ # discovered integration modules (resolved from installs)
110
+ ├── integrations.json # (service, account) credential records — written 0o600
111
+ └── store/
112
+ └── <service>.json # durable per-service runtime state (ctx.store)
113
+ ```
114
+
115
+ `getAgentIntegrationsDir(name)` and `getAgentIntegrationsPath(name)` resolve these paths.
116
+
117
+ ## Available Imports
118
+
119
+ | Package | Purpose |
120
+ |---------|---------|
121
+ | `@opsyhq/wolli` | Integration types (`IntegrationsAPI`, `IntegrationConfig`, `IntegrationOnboardContext`, `IntegrationRunContext`, `IntegrationActionContext`, `KeyValueStore`, `IntegrationHandle`) |
122
+ | `typebox` | Schemas (`Type`) for `account`, `events`, and action `parameters` |
123
+
124
+ The integration types are host-provided. A shipped integration package declares `@opsyhq/wolli` as a **peerDependency**, not a dependency — the host supplies it at load time. The package brings its own transport dependencies (the Telegram plugin bundles `grammy` + `@grammyjs/runner`; the scheduler bundles `croner`).
125
+
126
+ Node.js built-ins (`node:crypto`, etc.) are available. The integration runs in the host's Node.js process, so global `fetch`, `node:http`/`node:https`, `URL`, timers, and the other standard Node globals are all in scope. A transport that talks to a plain HTTP endpoint can use `fetch` directly; bundling a client library (as Telegram bundles `grammy`) is only needed for richer protocols.
127
+
128
+ ## Writing an Integration
129
+
130
+ An integration module default-exports a factory receiving `IntegrationsAPI`. Inside, call `wolli.registerIntegration(config)` once per service. The factory may be synchronous or asynchronous.
131
+
132
+ ```typescript
133
+ import type { IntegrationsAPI } from "@opsyhq/wolli";
134
+
135
+ export type IntegrationFactory = (wolli: IntegrationsAPI) => void | Promise<void>;
136
+ ```
137
+
138
+ `IntegrationsAPI` is small:
139
+
140
+ ```typescript
141
+ interface IntegrationsAPI {
142
+ registerIntegration(config: IntegrationConfig): void;
143
+ unregisterIntegration(name: string): void;
144
+ }
145
+ ```
146
+
147
+ `registerIntegration` writes the definition directly at load time. `unregisterIntegration(name)` is available for teardown; the shipped plugins register a single static service and never call it.
148
+
149
+ ## IntegrationConfig
150
+
151
+ The object passed to `registerIntegration`. Every field except a service identity is optional, so an integration can be pure-producer (events + `run`), pure-action (request/response only), or both.
152
+
153
+ ```typescript
154
+ interface IntegrationConfig {
155
+ name?: string; // service id; defaults to the file/dir basename
156
+ account?: TSchema; // schema for ONE configured account record
157
+ events?: Record<string, TSchema>; // named events this integration emits
158
+ actions?: Record<string, IntegrationAction>; // callable request/response functions
159
+ run?(ctx: IntegrationRunContext): void | (() => void) | Promise<void | (() => void)>;
160
+ onboard?(ctx: IntegrationOnboardContext): Promise<IntegrationAccountRecord | undefined>;
161
+ }
162
+ ```
163
+
164
+ - `name` — the service id consumers pass to `getIntegration(name, account)`. Defaults to the basename of the file or directory.
165
+ - `account` — a typebox schema for **one** configured account record. The host validates the persisted record against this both at onboarding time and before handing it to `run`/actions as `ctx.account`. Derive the static type from the schema with typebox `Static<>` to avoid drift, e.g. `const Account = Type.Object({ url: Type.String() }); type Account = Static<typeof Account>;` then `ctx.account as Account`. The same pattern applies to action `parameters`.
166
+ - `events` — a map of event name to payload schema. `ctx.emit(event, data)` validates `data` against the matching schema.
167
+ - `actions` — request/response functions consumers invoke with `.call(action, params)`. See [Actions](#actions).
168
+ - `run` — the long-running producer. See [The producer: run(ctx)](#the-producer-runctx).
169
+ - `onboard` — guided first-run setup. See [Onboarding](#onboarding).
170
+
171
+ > **Multiple accounts:** `account` describes a single record. Onboarding always writes the account key `"default"`. A second account (`getIntegration(name, "work")`) only exists if you add it to `integrations.json` by hand; nothing in the shipped flow creates one.
172
+
173
+ ## The producer: run(ctx)
174
+
175
+ `run` is the one genuinely new concept in wolli, which is otherwise pull/request-response. It opens a connection or loop and pushes inbound items out as events. The host calls `run` once per `(service, account)` after the account validates.
176
+
177
+ ```typescript
178
+ interface IntegrationRunContext {
179
+ account: unknown; // resolved + validated against config.account
180
+ emit(event: string, data: unknown): void; // validated against config.events[event]
181
+ store: KeyValueStore; // durable per-service runtime state
182
+ signal: AbortSignal; // aborted on stop(); one run() per (service, account)
183
+ }
184
+ ```
185
+
186
+ `emit` is fire-and-forget and never throws back into `run()`: an invalid payload (or a throwing listener) is captured by the host error sink, not raised at the emit call site. You do not need `try`/`catch` around `emit` for validation.
187
+
188
+ Lifecycle:
189
+
190
+ ```
191
+ account validated
192
+ └─► run(ctx) called
193
+ ├─ opens a connection / starts a timer loop
194
+ ├─ per inbound item: ctx.emit("<event>", data)
195
+ └─ returns a disposer () => void (optional)
196
+
197
+ /reload or shutdown
198
+ ├─► ctx.signal aborts (listen for cleanup)
199
+ └─► the returned disposer is called
200
+ ```
201
+
202
+ `run` may return a disposer function (sync or via a Promise). Wire teardown to **both** the returned disposer and a `ctx.signal` abort listener — a reload stops the old producer before starting the new one, and both paths must release the transport (the Telegram producer relies on this to avoid Telegram's 409 "two pollers on one token" conflict).
203
+
204
+ > The disposer must be idempotent. On stop the host aborts `ctx.signal` (firing your abort listener) AND calls the returned disposer — so any teardown wired to both paths runs twice. `clearInterval` is naturally idempotent; for a stateful teardown (e.g. a long-poll runner) guard it, as the Telegram producer does with `if (runner?.isRunning()) void runner.stop();`.
205
+
206
+ ```typescript
207
+ run(ctx) {
208
+ const timer = setInterval(() => ctx.emit("tick", { at: Date.now() }), 1000);
209
+ const dispose = () => clearInterval(timer);
210
+ ctx.signal.addEventListener("abort", dispose);
211
+ return dispose;
212
+ }
213
+ ```
214
+
215
+ > **Do not await a non-resolving runner.** If your transport runs a loop that never resolves (e.g. a long-poll runner), start it fire-and-forget and capture the handle for the disposer — never `await` it, or `run` never returns.
216
+
217
+ ## Durable state: ctx.store
218
+
219
+ `ctx.store` is a string-keyed `KeyValueStore` for machine-written runtime state, scoped to one service and backed by `~/.wolli/agents/<name>/store/<service>.json`. It is process-scoped and survives `/reload`. Use it where an integration keeps state it owns (the scheduler keeps its jobs here).
220
+
221
+ ```typescript
222
+ interface KeyValueStore {
223
+ get(key: string): unknown;
224
+ set(key: string, value: unknown): void;
225
+ getAll(): Record<string, unknown>;
226
+ delete(key: string): void;
227
+ }
228
+ ```
229
+
230
+ `get` returns `unknown`; cast to your stored shape at the boundary. The same store is reachable from `run` (`ctx.store`) and from action handlers (`ctx.store`), so a producer and its CRUD actions share one durable view — the scheduler's actions write jobs that its `run` loop reads each tick.
231
+
232
+ ## Actions
233
+
234
+ Actions are callable request/response functions — the talk-back surface. Each is a `ToolDefinition`-shaped object minus the params generic: a `parameters` schema plus an `execute` validated at the boundary.
235
+
236
+ ```typescript
237
+ interface IntegrationAction {
238
+ description?: string;
239
+ parameters: TSchema;
240
+ execute(params: unknown, ctx: IntegrationActionContext): Promise<unknown>;
241
+ }
242
+
243
+ interface IntegrationActionContext {
244
+ account: unknown; // resolved + validated against config.account
245
+ store: KeyValueStore;
246
+ signal: AbortSignal;
247
+ }
248
+ ```
249
+
250
+ `params` arrives validated against `parameters`; cast it to your typed shape inside `execute`. The return value is passed back to the caller of `.call(action, params)`. Use `ctx.account` to reach configured credentials and `ctx.store` for shared durable state.
251
+
252
+ ```typescript
253
+ actions: {
254
+ sendMessage: {
255
+ description: "Send a text message to a chat.",
256
+ parameters: Type.Object({ chatId: Type.Number(), text: Type.String() }),
257
+ execute: async (params, ctx) => {
258
+ const { chatId, text } = params as { chatId: number; text: string };
259
+ const account = ctx.account as { botToken: string };
260
+ // ... use account.botToken to send ...
261
+ return { ok: true };
262
+ },
263
+ },
264
+ }
265
+ ```
266
+
267
+ ## Onboarding
268
+
269
+ `onboard(ctx)` is guided first-run setup. It auto-runs on `plugins install` for an unconfigured service when attached to a TTY, and on demand via `plugins configure <source>`. It returns **one** account record to persist, or `undefined` to cancel.
270
+
271
+ ```typescript
272
+ interface IntegrationOnboardContext {
273
+ ui: IntegrationOnboardUI; // select / confirm / input / notify only
274
+ resolve: typeof resolveConfigValueUncached; // test a $ENV / ${ENV} / !cmd reference live
275
+ signal: AbortSignal;
276
+ }
277
+
278
+ type IntegrationOnboardUI = Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
279
+ ```
280
+
281
+ The `ui` surface is narrowed to the four dialog primitives. Chat chrome (editors, widgets, custom components) is excluded because onboarding dialogs serialize to attached clients over the wire; calling anything outside this set is a compile error, not a silent no-op.
282
+
283
+ ```typescript
284
+ ui.select(title: string, options: string[], opts?): Promise<string | undefined>;
285
+ ui.confirm(title: string, message: string, opts?): Promise<boolean>;
286
+ ui.input(title: string, placeholder?: string, opts?): Promise<string | undefined>;
287
+ ui.notify(message: string, type?: "info" | "warning" | "error"): void;
288
+ ```
289
+
290
+ `select` and `input` resolve to `undefined` when the user cancels (escape) — branch on it to abort onboarding by returning `undefined`. `confirm` resolves to a boolean. `notify` is fire-and-forget.
291
+
292
+ What the host does with the returned record:
293
+
294
+ ```
295
+ onboard(ctx) returns record (or undefined → cancelled)
296
+ └─► host resolves each string field ($ENV / !cmd)
297
+ └─► validates the resolved record against config.account
298
+ └─► persists ONLY if valid → integrations.json ("<service>" → "default")
299
+ ```
300
+
301
+ Record values may be raw secrets or `$ENV` / `${ENV}` / `!cmd` references. `integrations.json` is written `0o600`. `ctx.resolve` lets `onboard` test a credential before returning it (e.g. resolve a `$TOKEN` reference and make a verifying API call). Returning a record whose resolved form fails the `account` schema is reported as an error and nothing is persisted.
302
+
303
+ ```typescript
304
+ async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
305
+ const entered = await ctx.ui.input("Paste the bot token");
306
+ if (entered === undefined) return undefined; // cancelled
307
+ const token = entered.trim();
308
+ if (!token) {
309
+ ctx.ui.notify("No token entered.", "error");
310
+ return undefined;
311
+ }
312
+ // ... verify token with a live call here ...
313
+ return { botToken: token };
314
+ }
315
+ ```
316
+
317
+ An integration with no secret still benefits from a trivial `onboard` that returns `{}` — that writes the `"<service>.default"` account so `run` starts. (The scheduler does exactly this.)
318
+
319
+ ## Consuming an Integration
320
+
321
+ The mapping half is an ordinary extension. It reaches the transport through `wolli.getIntegration(name, account?)`, which returns an `IntegrationHandle`. `account` defaults to `"default"`.
322
+
323
+ ```typescript
324
+ interface IntegrationHandle {
325
+ on(event: string, handler: (data: unknown) => void | Promise<void>): () => void; // returns unsubscribe
326
+ call(action: string, params?: unknown): Promise<unknown>;
327
+ }
328
+ ```
329
+
330
+ - `.on(event, handler)` subscribes to a producer event and returns an unsubscribe function. `data` is `unknown`; cast it to the event's payload shape. Events are delivered only to listeners attached at emit time; there is no buffering. An event emitted before any extension has called `.on` for it is dropped. For a producer that emits a catch-up batch on start, this means events fired before the paired extension subscribes are missed — design the producer to (re)emit on the next cycle rather than assume a subscriber exists at the first tick.
331
+ - `.call(action, params)` invokes an action; `params` is validated against the action's schema before `execute` runs, and the resolved return value comes back. When an action takes no parameters (schema `Type.Object({})`), call it as `.call("action")` with `params` omitted — the host substitutes `{}` before validation.
332
+
333
+ > **Onboard before you consume.** `getIntegration(name, account)` **throws if the integration or account is not configured**. Extensions call it at the factory top (load time), so an unconfigured account surfaces as a per-extension load error caught by the host — the mapping extension fails to load until the account exists. This is why the install/configure flow onboards the account before the paired extension activates (on the next launch). There is no deferred handle that silently defers the throw.
334
+
335
+ ```typescript
336
+ import type { ExtensionAPI } from "@opsyhq/wolli";
337
+
338
+ export default function (wolli: ExtensionAPI) {
339
+ const tg = wolli.getIntegration("telegram", "default"); // throws if telegram.default not configured
340
+
341
+ tg.on("message", async (data) => {
342
+ const m = data as { chatId: number; text: string };
343
+ // ... map the event onto a session ...
344
+ });
345
+
346
+ await tg.call("sendMessage", { chatId: 123, text: "hi" });
347
+ }
348
+ ```
349
+
350
+ Two surfaces exist but the shipped plugins use neither, so they are noted as available rather than demonstrated here: `IntegrationOnboardContext` threads a `resolve` field (a live `$ENV`/`!cmd` resolver for testing a credential mid-onboarding), and `IntegrationsAPI.unregisterIntegration(name)` tears a definition back down. The shipped plugins register one static service at load and never call `unregisterIntegration`.
351
+
352
+ ## The dual-half package
353
+
354
+ A shipped integration is one package declaring both halves under its package.json `"wolli"` key:
355
+
356
+ ```json
357
+ {
358
+ "name": "wolli-integration-telegram",
359
+ "type": "module",
360
+ "wolli": {
361
+ "integrations": ["./index.ts"],
362
+ "extensions": ["./telegram-chat.ts"]
363
+ },
364
+ "dependencies": { "grammy": "1.44.0", "@grammyjs/runner": "2.0.3" },
365
+ "peerDependencies": { "@opsyhq/wolli": "*" }
366
+ }
367
+ ```
368
+
369
+ `"integrations"` lists the transport module(s); `"extensions"` lists the mapping module(s). A single `plugins install` installs the package once. The paired extension is **resolved in place by the package manager from that same install** — it is not copied into the agent's `extensions/` folder. After onboarding configures the account, the mapping extension activates on the next launch.
370
+
371
+ Why the split is load-bearing: the transport (`index.ts`) never touches sessions or the agent; the extension (`*-chat.ts`) owns the mapping between the external thread and a Wolli session. Swapping the mapping is an extension change; the transport is untouched.
372
+
373
+ ## Installing and configuring
374
+
375
+ The CLI verb is `plugins`, and **the agent name precedes the verb**:
376
+
377
+ ```bash
378
+ wolli <agent> plugins install <source> # install + auto-onboard (on a TTY)
379
+ wolli <agent> plugins configure <source> # re-run guided setup (requires a TTY)
380
+ wolli <agent> plugins list # list installed plugins + contributed integrations
381
+ wolli <agent> plugins remove <source> # remove the plugin
382
+ wolli <agent> plugins update [source] # update one or all plugins
383
+ ```
384
+
385
+ Sources:
386
+
387
+ | Form | Example |
388
+ |------|---------|
389
+ | local | `wolli <agent> plugins install ./plugins/telegram` |
390
+ | npm | `wolli <agent> plugins install npm:@scope/pkg` |
391
+ | git | `wolli <agent> plugins install git:github.com/user/repo` |
392
+
393
+ On `install`, if the plugin's integration declares `onboard` and the terminal is interactive, guided setup runs immediately (rendered in a startup TUI over the daemon's UI round-trip). When headless, install points you at `plugins configure <source>` to set it up later. `configure` re-runs the guided setup even when the account already exists, and requires an interactive terminal.
394
+
395
+ ## Error Handling
396
+
397
+ Integration errors ride the same sink as extension errors (`IntegrationError` mirrors `ExtensionError`), so they surface alongside extension load/runtime errors.
398
+
399
+ Failure modes to design for:
400
+
401
+ - **Producer-side errors.** Swallow transient transport failures inside `run` so a single poll/tick failure cannot crash the host. The Telegram producer installs a `bot.catch(...)` and logs; a hard throw out of `run` becomes a load error.
402
+ - **Action errors.** Throwing from an action's `execute` rejects the consumer's `.call(...)` Promise. Catch around `.call` in the mapping extension and degrade (log, retry, or surface to the agent) rather than letting the rejection escape a fire-and-forget callback.
403
+ - **Listener errors.** A throwing or rejecting `.on(event, handler)` is caught by the host and reported on the same error sink (it never crashes the producer), so an unguarded async `on`-handler is safe — though catching to degrade gracefully is still good practice.
404
+ - **Unconfigured account.** `getIntegration` throws at extension load if the account is missing — this is the onboard-before-consume ordering above, surfaced as a per-extension load error, not a runtime exception during an event.
405
+ - **Validation.** `ctx.emit` validates payloads against `events[event]`, `.call` validates params against the action schema, and onboarding validates the resolved record against `account`. A schema mismatch is reported, not silently dropped.
406
+
407
+ ## Worked example: Telegram (bidirectional chat)
408
+
409
+ The Telegram plugin is the canonical dual-half integration. The transport (`index.ts`) long-polls grammY, holds the bot token, and emits a `message` event per inbound message. It exposes `sendMessage`, `sendChatAction`, and `setCommands` actions. The mapping (`telegram-chat.ts`) routes each message into a per-chat Wolli session and ships the reply back.
410
+
411
+ The transport — events, an action, onboarding, and the long-poll producer:
412
+
413
+ ```typescript
414
+ import { run } from "@grammyjs/runner";
415
+ import type { IntegrationOnboardContext, IntegrationsAPI } from "@opsyhq/wolli";
416
+ import { Bot } from "grammy";
417
+ import { Type } from "typebox";
418
+
419
+ interface TelegramAccount {
420
+ botToken: string;
421
+ allowedChatIds?: number[];
422
+ parseMode?: "MarkdownV2" | "HTML" | "plain";
423
+ }
424
+
425
+ async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
426
+ const entered = await ctx.ui.input("Paste the bot token from BotFather");
427
+ if (entered === undefined) return undefined;
428
+ const token = entered.trim();
429
+ if (!token) {
430
+ ctx.ui.notify("No token entered.", "error");
431
+ return undefined;
432
+ }
433
+ try {
434
+ const me = await new Bot(token).api.getMe();
435
+ ctx.ui.notify(`Verified bot @${me.username}.`, "info");
436
+ } catch (err) {
437
+ ctx.ui.notify(`Could not verify the token: ${err instanceof Error ? err.message : String(err)}`, "error");
438
+ return undefined;
439
+ }
440
+ return { botToken: token };
441
+ }
442
+
443
+ export default function (wolli: IntegrationsAPI) {
444
+ wolli.registerIntegration({
445
+ name: "telegram",
446
+ account: Type.Object({
447
+ botToken: Type.String(),
448
+ allowedChatIds: Type.Optional(Type.Array(Type.Number())),
449
+ parseMode: Type.Optional(Type.Union([Type.Literal("MarkdownV2"), Type.Literal("HTML"), Type.Literal("plain")])),
450
+ }),
451
+ events: {
452
+ message: Type.Object({
453
+ chatId: Type.Number(),
454
+ messageId: Type.Number(),
455
+ text: Type.String(),
456
+ from: Type.Object({ id: Type.Number(), username: Type.Optional(Type.String()) }),
457
+ chatType: Type.String(),
458
+ date: Type.Number(),
459
+ }),
460
+ },
461
+ onboard,
462
+ actions: {
463
+ sendMessage: {
464
+ description: "Send a text message to a chat.",
465
+ parameters: Type.Object({
466
+ chatId: Type.Number(),
467
+ text: Type.String(),
468
+ replyToMessageId: Type.Optional(Type.Number()),
469
+ }),
470
+ execute: async (params, ctx) => {
471
+ const { chatId, text } = params as { chatId: number; text: string };
472
+ const account = ctx.account as TelegramAccount;
473
+ const sent = await new Bot(account.botToken).api.sendMessage(chatId, text);
474
+ return { messageIds: [sent.message_id] };
475
+ },
476
+ },
477
+ },
478
+ run(ctx) {
479
+ const { botToken } = ctx.account as TelegramAccount;
480
+ const bot = new Bot(botToken);
481
+
482
+ bot.on("message:text", (c) => {
483
+ if (c.from?.id === c.me.id) return; // ignore our own messages
484
+ ctx.emit("message", {
485
+ chatId: c.chat.id,
486
+ messageId: c.msg.message_id,
487
+ text: c.msg.text,
488
+ from: { id: c.from?.id ?? 0, username: c.from?.username },
489
+ chatType: c.chat.type,
490
+ date: c.msg.date,
491
+ });
492
+ });
493
+
494
+ bot.catch((err) => console.error("[telegram] bot error:", err.message));
495
+
496
+ // Fire-and-forget: never await the runner (it never resolves).
497
+ let runner: ReturnType<typeof run> | undefined;
498
+ void bot.api.deleteWebhook({ drop_pending_updates: true }).then(() => {
499
+ if (ctx.signal.aborted) return;
500
+ runner = run(bot);
501
+ });
502
+
503
+ const dispose = () => {
504
+ if (runner?.isRunning()) void runner.stop();
505
+ };
506
+ ctx.signal.addEventListener("abort", dispose);
507
+ return dispose;
508
+ },
509
+ });
510
+ }
511
+ ```
512
+
513
+ The mapping — bind each chat to its own session via a tag, route inbound text in, ship the reply back on `agent_end`:
514
+
515
+ ```typescript
516
+ import type { ExtensionAPI } from "@opsyhq/wolli";
517
+
518
+ interface TelegramMessage {
519
+ chatId: number;
520
+ text: string;
521
+ }
522
+
523
+ export default function (wolli: ExtensionAPI) {
524
+ const tg = wolli.getIntegration("telegram", "default");
525
+
526
+ // inbound: route each message into its chat's own session.
527
+ tg.on("message", async (data) => {
528
+ const m = data as TelegramMessage;
529
+ const chatTag = { "telegram:chat": String(m.chatId) };
530
+ const [match] = await wolli.findSessions(chatTag);
531
+ const session = match
532
+ ? await wolli.openSession(match.id)
533
+ : await wolli.createSession({
534
+ setup: async (sessionManager) => {
535
+ await sessionManager.appendTags(chatTag);
536
+ },
537
+ });
538
+ // followUp queues cleanly if a turn is already in flight.
539
+ void session.sendUserMessage(m.text, { deliverAs: "followUp" });
540
+ });
541
+
542
+ // outbound: the reply rides the PRODUCING session's tag, so it returns to the
543
+ // chat that started this turn — not whoever messaged last.
544
+ wolli.on("agent_end", async ({ messages }, ctx) => {
545
+ const chat = ctx.session.getTags()["telegram:chat"];
546
+ if (!chat) return; // not a telegram-bound session
547
+ const text = finalAssistantText(messages); // last assistant text; "" for a pure tool-call turn
548
+ if (!text) return;
549
+ await tg.call("sendMessage", { chatId: Number(chat), text });
550
+ });
551
+ }
552
+ ```
553
+
554
+ Key mechanics:
555
+ - **Session binding via tags.** Each chat gets its own session, bound by `{ "telegram:chat": <id> }`. `findSessions(tag)` locates it; `createSession({ setup })` lazily creates and tags a fresh one through the `SessionManager.appendTags(...)` call. Two chats run in parallel.
556
+ - **Reply routing.** `agent_end` reads the tag off the **producing** session (`ctx.session.getTags()`), so the answer returns to the chat that started the turn.
557
+ - **Delivery.** `sendUserMessage(text, { deliverAs: "followUp" })` queues mid-stream messages cleanly instead of interrupting.
558
+
559
+ ## Worked example: Scheduler (timer to wake)
560
+
561
+ The scheduler plugin is a producer with no secret. Its transport (`index.ts`) owns the jobs (persisted in `ctx.store`), ticks a coarse timer, and emits `due` when a job's time arrives. CRUD actions (`addJob`, `listJobs`, `updateJob`, `removeJob`, `runJob`) let the agent manage jobs. The mapping (`scheduler-chat.ts`) registers a `cron` tool over those actions and, on `due`, wakes the originating session.
562
+
563
+ The transport — `ctx.store`-backed jobs, an action, a no-secret onboard, and the tick loop:
564
+
565
+ ```typescript
566
+ import { randomUUID } from "node:crypto";
567
+ import type { IntegrationOnboardContext, IntegrationsAPI, KeyValueStore } from "@opsyhq/wolli";
568
+ import { type Static, Type } from "typebox";
569
+
570
+ const Schedule = Type.Union([
571
+ Type.Object({ kind: Type.Literal("at"), at: Type.Number() }),
572
+ Type.Object({ kind: Type.Literal("every"), everyMs: Type.Number() }),
573
+ Type.Object({ kind: Type.Literal("cron"), expr: Type.String(), tz: Type.Optional(Type.String()) }),
574
+ ]);
575
+ type Schedule = Static<typeof Schedule>;
576
+
577
+ interface Job {
578
+ id: string;
579
+ prompt: string;
580
+ schedule: Schedule;
581
+ enabled: boolean;
582
+ originTags?: Record<string, string>;
583
+ nextRunAt: number;
584
+ }
585
+
586
+ function loadJobs(store: KeyValueStore): Record<string, Job> {
587
+ return (store.get("jobs") as Record<string, Job> | undefined) ?? {};
588
+ }
589
+
590
+ // No secret: writing an empty account is enough for run() to start.
591
+ async function onboard(ctx: IntegrationOnboardContext): Promise<Record<string, unknown>> {
592
+ ctx.ui.notify("Scheduler enabled.", "info");
593
+ return {};
594
+ }
595
+
596
+ export default function (wolli: IntegrationsAPI) {
597
+ wolli.registerIntegration({
598
+ name: "scheduler",
599
+ account: Type.Object({ tickMs: Type.Optional(Type.Number()) }),
600
+ events: {
601
+ due: Type.Object({
602
+ id: Type.String(),
603
+ prompt: Type.String(),
604
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
605
+ name: Type.Optional(Type.String()),
606
+ }),
607
+ },
608
+ onboard,
609
+ actions: {
610
+ addJob: {
611
+ description: "Schedule a new job from a prompt and a schedule.",
612
+ parameters: Type.Object({
613
+ prompt: Type.String(),
614
+ schedule: Schedule,
615
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
616
+ }),
617
+ execute: async (params, ctx) => {
618
+ const p = params as { prompt: string; schedule: Schedule; originTags?: Record<string, string> };
619
+ const job: Job = {
620
+ id: randomUUID(),
621
+ prompt: p.prompt,
622
+ schedule: p.schedule,
623
+ enabled: true,
624
+ originTags: p.originTags,
625
+ nextRunAt: p.schedule.kind === "at" ? p.schedule.at : Date.now() + 1000,
626
+ };
627
+ const jobs = loadJobs(ctx.store);
628
+ jobs[job.id] = job;
629
+ ctx.store.set("jobs", jobs);
630
+ return { id: job.id, nextRunAt: job.nextRunAt };
631
+ },
632
+ },
633
+ },
634
+ run(ctx) {
635
+ const { tickMs } = ctx.account as { tickMs?: number };
636
+ const tick = () => {
637
+ const now = Date.now();
638
+ const jobs = loadJobs(ctx.store);
639
+ const due: Job[] = [];
640
+ for (const job of Object.values(jobs)) {
641
+ if (!job.enabled || job.nextRunAt > now) continue;
642
+ if (job.schedule.kind === "at") job.enabled = false; // one-shot
643
+ due.push(job);
644
+ }
645
+ if (due.length === 0) return;
646
+ // Persist the advanced state BEFORE emitting, so a crash right after never re-fires.
647
+ ctx.store.set("jobs", jobs);
648
+ for (const job of due) {
649
+ ctx.emit("due", { id: job.id, prompt: job.prompt, originTags: job.originTags });
650
+ }
651
+ };
652
+ tick(); // one catch-up tick on start
653
+ const timer = setInterval(tick, tickMs ?? 60_000);
654
+ const dispose = () => clearInterval(timer);
655
+ ctx.signal.addEventListener("abort", dispose);
656
+ return dispose;
657
+ },
658
+ });
659
+ }
660
+ ```
661
+
662
+ The mapping — a `cron` tool over the CRUD actions, and a `due` handler that wakes the origin session:
663
+
664
+ ```typescript
665
+ import type { ExtensionAPI } from "@opsyhq/wolli";
666
+ import { Type } from "typebox";
667
+
668
+ export default function (wolli: ExtensionAPI) {
669
+ const sched = wolli.getIntegration("scheduler", "default");
670
+
671
+ wolli.registerTool({
672
+ name: "cron",
673
+ label: "Cron",
674
+ description: "Schedule prompts to run later (add / list / update / remove / run).",
675
+ parameters: Type.Object({
676
+ action: Type.String(),
677
+ prompt: Type.Optional(Type.String()),
678
+ at: Type.Optional(Type.Number()),
679
+ }),
680
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
681
+ if (params.action === "add" && params.prompt && params.at !== undefined) {
682
+ // Snapshot the scheduling session's tags so the fired result returns to this surface.
683
+ const result = await sched.call("addJob", {
684
+ prompt: params.prompt,
685
+ schedule: { kind: "at", at: params.at },
686
+ originTags: ctx.session.getTags(),
687
+ });
688
+ return { content: [{ type: "text", text: "Scheduled." }], details: result };
689
+ }
690
+ return { content: [{ type: "text", text: "Unsupported action." }], details: {} };
691
+ },
692
+ });
693
+
694
+ sched.on("due", async (data) => {
695
+ const job = data as { prompt: string; originTags?: Record<string, string> };
696
+ // Run the prompt in the newest session matching the origin tags. A telegram-tagged
697
+ // origin → telegram's own agent_end ships the reply to that chat; no scheduler-side
698
+ // channel handling. Create a SAME-tagged session if none matches, never an untagged one.
699
+ const [match] = await wolli.findSessions(job.originTags ?? {});
700
+ const session = match
701
+ ? await wolli.openSession(match.id)
702
+ : await wolli.createSession({
703
+ setup: async (sessionManager) => {
704
+ await sessionManager.appendTags(job.originTags ?? {});
705
+ },
706
+ });
707
+ await session.sendUserMessage(job.prompt, { deliverAs: "followUp" });
708
+ });
709
+ }
710
+ ```
711
+
712
+ Key mechanics:
713
+ - **Producer owns durable state.** Jobs live in `ctx.store` under one key (`"jobs"`); the tick loop and the CRUD actions share that view. State is persisted **before** `emit`, so a crash right after firing never double-fires (at-most-once).
714
+ - **Tag-routed delivery, no channel coupling.** `addJob` snapshots the scheduling session's tags as `originTags`. When the job fires, the prompt runs in the newest session matching those tags — so a telegram-tagged origin gets its reply shipped by telegram's own `agent_end`, with no scheduler-side special-casing.
715
+ - **No secret.** Onboarding writes an empty `{}` account so `run` starts; the agent schedules its own jobs through the `cron` tool.