wolli 0.0.3 → 0.0.4
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.
- package/README.md +23 -24
- package/built-in/plugins/discord/discord-chat.ts +47 -84
- package/built-in/plugins/discord/index.ts +139 -97
- package/built-in/plugins/discord/package.json +2 -2
- package/built-in/plugins/scheduler/cron.ts +131 -0
- package/built-in/plugins/scheduler/index.ts +147 -151
- package/built-in/plugins/scheduler/package.json +3 -2
- package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
- package/built-in/plugins/telegram/README.md +3 -3
- package/built-in/plugins/telegram/index.ts +177 -139
- package/built-in/plugins/telegram/package.json +2 -2
- package/built-in/plugins/telegram/telegram-chat.ts +79 -155
- package/dist/cli.js +5696 -5841
- package/docs/hooks.md +103 -0
- package/docs/index.md +10 -8
- package/docs/integrations.md +78 -663
- package/docs/introduction.md +95 -0
- package/docs/plugins.md +43 -36
- package/docs/providers.md +63 -0
- package/docs/sdk.md +42 -47
- package/docs/tools.md +81 -0
- package/docs/workflows.md +170 -0
- package/package.json +1 -1
- package/built-in/plugins/scheduler/scheduler-chat.ts +0 -164
- package/docs/extensions.md +0 -2331
package/docs/integrations.md
CHANGED
|
@@ -1,715 +1,130 @@
|
|
|
1
1
|
# Integrations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The transport between a platform and your agent: credentials, inbound events, and callable actions.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
An integration is a transport that connects your agent to an outside platform (Telegram, Discord, an internal API). It faces the network, holds the credentials, turns platform activity into events, and exposes actions that talk back. It does **not** route: an integration never reaches a session and never decides what the agent does with an event. Workflows own that; the integration's job ends at the wire.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Extensions listen.** An extension subscribes to those events, decides which matter, and maps them onto sessions.
|
|
7
|
+
## The integration file
|
|
9
8
|
|
|
10
|
-
|
|
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:
|
|
9
|
+
Integrations live under `integrations/` in the agent home, one service per file. The file basename is the service name, so `integrations/telegram.ts` registers `telegram`. The definition is the module's default export:
|
|
39
10
|
|
|
40
11
|
```typescript
|
|
41
|
-
|
|
12
|
+
// ~/.wolli/agents/assistant/integrations/telegram.ts
|
|
13
|
+
import { defineIntegration } from "wolli";
|
|
42
14
|
import { Type } from "typebox";
|
|
43
15
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
16
|
+
const telegram = defineIntegration({
|
|
17
|
+
account: Type.Object({ botToken: Type.String() }),
|
|
18
|
+
events: {
|
|
19
|
+
message: Type.Object({ chatId: Type.Number(), text: Type.String() }),
|
|
20
|
+
},
|
|
21
|
+
actions: {
|
|
22
|
+
sendMessage: {
|
|
23
|
+
description: "Send a text message to a chat.",
|
|
24
|
+
parameters: Type.Object({ chatId: Type.Number(), text: Type.String() }),
|
|
25
|
+
async execute(params, ctx) {
|
|
26
|
+
// ctx.account is the resolved record; params arrive validated.
|
|
27
|
+
// Send through the Bot API and return the sent message ids.
|
|
56
28
|
},
|
|
57
29
|
},
|
|
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
30
|
},
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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"`.
|
|
31
|
+
async run(ctx) {
|
|
32
|
+
// Open a grammY long-poll connection with ctx.account's botToken.
|
|
33
|
+
// Per inbound text message:
|
|
34
|
+
// ctx.emit("message", { chatId: msg.chat.id, text: msg.text });
|
|
35
|
+
// Return a disposer that stops the poller; ctx.signal aborts on reload.
|
|
36
|
+
return () => {
|
|
37
|
+
/* stop the poller */
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
});
|
|
322
41
|
|
|
323
|
-
|
|
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
|
-
}
|
|
42
|
+
export default telegram;
|
|
328
43
|
```
|
|
329
44
|
|
|
330
|
-
|
|
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
|
-
});
|
|
45
|
+
The exported value is more than configuration. It is a typed handle other files import: a workflow binds an event with `telegram.on("message", run)` and calls `sendMessage` through `ctx.integration(telegram)`.
|
|
345
46
|
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
```
|
|
47
|
+
## Accounts and onboarding
|
|
349
48
|
|
|
350
|
-
|
|
49
|
+
Credentials live outside the code, in `integrations.json` in the agent home. The file maps each service to its ONE account record; onboarding writes it.
|
|
351
50
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
A shipped integration is one package declaring both halves under its package.json `"wolli"` key:
|
|
51
|
+
`~/.wolli/agents/assistant/integrations.json`
|
|
355
52
|
|
|
356
53
|
```json
|
|
357
54
|
{
|
|
358
|
-
"
|
|
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": "*" }
|
|
55
|
+
"telegram": { "botToken": "$TELEGRAM_BOT_TOKEN" }
|
|
366
56
|
}
|
|
367
57
|
```
|
|
368
58
|
|
|
369
|
-
|
|
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.
|
|
59
|
+
Record values may be raw secrets or `$ENV`, `${ENV}`, or `!cmd` references. wolli resolves references on read, validates the resolved record against the `account` schema, and hands it to `run` and to actions as `ctx.account`. A record that fails the schema is reported and never reaches your code. The file is written `0o600`.
|
|
372
60
|
|
|
373
|
-
|
|
61
|
+
A second account is a second integration file: `telegram-work.ts` is the service `telegram-work`, with its own record, its own store, and its own producer, and workflows import the instance they mean. An integration that needs dynamic multi-tenancy models it inside its own account schema — an array of tenants, `onboard` collects them, `run` opens one connection per entry.
|
|
374
62
|
|
|
375
|
-
|
|
63
|
+
`onboard(ctx)` is guided first-run setup. It runs on `plugins install` for an unconfigured service, or on demand via `plugins configure` (see [Plugins](./plugins.md)). It returns one account record to persist, or `undefined` to cancel. `ctx.ui` carries exactly four dialog primitives, `select`, `confirm`, `input`, and `notify`, because onboarding dialogs serialize to attached clients over the wire. The telegram service collects and verifies the token:
|
|
376
64
|
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
65
|
+
```typescript
|
|
66
|
+
async onboard(ctx) {
|
|
67
|
+
const token = await ctx.ui.input("Paste the bot token from BotFather");
|
|
68
|
+
if (token === undefined) return undefined; // cancelled
|
|
69
|
+
// Verify with a live getMe() call before returning.
|
|
70
|
+
return { botToken: token.trim() };
|
|
71
|
+
},
|
|
383
72
|
```
|
|
384
73
|
|
|
385
|
-
|
|
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.
|
|
74
|
+
`ctx.resolve` resolves a `$ENV` or `!cmd` reference to its live value, so onboarding can test a credential before persisting the reference.
|
|
394
75
|
|
|
395
|
-
##
|
|
76
|
+
## Events
|
|
396
77
|
|
|
397
|
-
|
|
78
|
+
`events` maps each event name to a TypeBox payload schema. Inside `run`, the producer publishes with `ctx.emit("message", data)`, and wolli validates `data` against the declared schema at that boundary. An invalid payload is dropped and reported on the error sink; it is never delivered. `emit` never throws back into the producer, so no `try`/`catch` is needed around it.
|
|
398
79
|
|
|
399
|
-
|
|
80
|
+
The definition exposes each event two ways: `telegram.on("message", run)` binds it to a workflow directly, and `telegram.events.message` is the inert descriptor it funnels through — data carrying the service, the event name, and the payload type. Either way the handler's payload is typed from the schema. See [Workflows](./workflows.md) for triggers.
|
|
400
81
|
|
|
401
|
-
|
|
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)
|
|
82
|
+
## Actions
|
|
408
83
|
|
|
409
|
-
|
|
84
|
+
An action is a callable request/response function: a `parameters` schema plus `execute(params, ctx)`. wolli validates `params` against the schema before `execute` runs, so the body receives checked input. `ctx` carries the resolved `account`, the durable `store`, and an abort `signal`. Return serializable data; it flows back to the caller.
|
|
410
85
|
|
|
411
|
-
|
|
86
|
+
Actions are not callable on the definition itself. A workflow calls them through the typed handle:
|
|
412
87
|
|
|
413
88
|
```typescript
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
}
|
|
89
|
+
// inside a workflow handler
|
|
90
|
+
const tg = ctx.integration(telegram);
|
|
91
|
+
await tg.sendMessage({ chatId: 123456789, text: "Deployed." });
|
|
511
92
|
```
|
|
512
93
|
|
|
513
|
-
|
|
94
|
+
Transport state that must live in memory belongs in actions too. Telegram's typing indicator expires after a few seconds, so keeping it visible for a whole turn means re-sending it on an interval. The integration owns that as a `startTyping`/`stopTyping` action pair managing the timer internally; a workflow calls `startTyping` when a turn begins and `stopTyping` when it ends, and never holds the timer itself. Workflow files must not rely on module state; in-memory coordination lands in the integration.
|
|
514
95
|
|
|
515
|
-
|
|
516
|
-
import type { ExtensionAPI } from "@opsyhq/wolli";
|
|
96
|
+
## The producer run(ctx)
|
|
517
97
|
|
|
518
|
-
|
|
519
|
-
chatId: number;
|
|
520
|
-
text: string;
|
|
521
|
-
}
|
|
98
|
+
`run` is the long-lived half. It opens a connection or a timer loop and pushes each inbound item out with `ctx.emit`. wolli calls `run` once per configured integration, after its account record validates: one producer per integration.
|
|
522
99
|
|
|
523
|
-
|
|
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.
|
|
100
|
+
`run` may return a disposer. On reload or shutdown wolli aborts `ctx.signal` and calls the disposer, so wire teardown to both paths and make it idempotent; both fire on every stop. A reload stops the old producer before starting the new one, which is what keeps a single poller on the token. Do not `await` a loop that never resolves; start it fire-and-forget and keep the handle for the disposer.
|
|
558
101
|
|
|
559
|
-
##
|
|
102
|
+
## Durable state
|
|
560
103
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
The transport — `ctx.store`-backed jobs, an action, a no-secret onboard, and the tick loop:
|
|
104
|
+
`ctx.store` is a string-keyed key-value store scoped to the service and backed by `store/<service>.json` in the agent home. It survives reloads. `get` returns `unknown`; cast at the boundary:
|
|
564
105
|
|
|
565
106
|
```typescript
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
}
|
|
107
|
+
const cursor = ctx.store.get("cursor") as number | undefined;
|
|
108
|
+
ctx.store.set("cursor", lastUpdateId);
|
|
109
|
+
```
|
|
585
110
|
|
|
586
|
-
|
|
587
|
-
return (store.get("jobs") as Record<string, Job> | undefined) ?? {};
|
|
588
|
-
}
|
|
111
|
+
The same store is reachable from `run` and from every action, so the producer and its actions share one durable view. Use it for machine-written transport state (a resume cursor, a job list), not for routing state; routing rides session tags, which belong to workflows.
|
|
589
112
|
|
|
590
|
-
|
|
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
|
-
```
|
|
113
|
+
## Integration or workflow
|
|
661
114
|
|
|
662
|
-
|
|
115
|
+
| Need | Use |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| Hold a credential and call a platform API | An integration action |
|
|
118
|
+
| Turn inbound traffic into events | The integration's `run` loop |
|
|
119
|
+
| Decide which session receives a message | A workflow |
|
|
120
|
+
| Send the agent's reply back to a chat | A workflow calling an integration action |
|
|
121
|
+
| An in-memory timer shared across turns | An integration action pair |
|
|
122
|
+
| Machine-written state that survives reloads | `ctx.store` |
|
|
663
123
|
|
|
664
|
-
|
|
665
|
-
import type { ExtensionAPI } from "@opsyhq/wolli";
|
|
666
|
-
import { Type } from "typebox";
|
|
124
|
+
If the code needs a session, it is a workflow. If it needs the token, it lands in the integration.
|
|
667
125
|
|
|
668
|
-
|
|
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
|
-
```
|
|
126
|
+
## What to read next
|
|
711
127
|
|
|
712
|
-
|
|
713
|
-
-
|
|
714
|
-
-
|
|
715
|
-
- **No secret.** Onboarding writes an empty `{}` account so `run` starts; the agent schedules its own jobs through the `cron` tool.
|
|
128
|
+
- [Workflows](./workflows.md): triggers, `ctx.integration`, and routing events into sessions
|
|
129
|
+
- [Plugins](./plugins.md): installing packaged integrations and running onboarding
|
|
130
|
+
- [Tools](./tools.md): capabilities the model calls inside a session
|