@kaiba-cloud/orbital 0.0.0-stage → 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,1132 @@
1
+ # Ports: each port of `PluginPorts`
2
+
3
+ Each section has the same parts:
4
+
5
+ - **For**: what the port does for the node.
6
+ - **When**: when the node calls each method.
7
+ - **Config**: how `orbital.yaml` chooses the plugin for the port.
8
+ - **Interface**: the TypeScript interface, as `ports.ts` in `@kaiba-cloud/orbital` declares it.
9
+ - **Remote methods**: the JSON-RPC methods for a plugin out of process, with their named params. The SDK maps them to your object. You need them only when you write a plugin without the SDK.
10
+ - **Example**: a minimal port that passes `orbital plugin check`.
11
+
12
+ Every type comes from `@kaiba-cloud/orbital-sdk`. Each argument and each answer
13
+ is plain JSON. A method that the interface marks with `?` is optional. Leave it
14
+ out, and the manifest says that the plugin does not provide it.
15
+
16
+ The order of plugins: first the plugins of `orbital.yaml`, in the order of
17
+ `plugins:`, then the built-in plugins `orbital`, `local`, `static`, `git` and
18
+ `folder`. "In plugin order" below means this order. So your plugin answers a
19
+ hook such as `sessions.create` before a built-in plugin does.
20
+
21
+ ## `directory`
22
+
23
+ **For.** Give the node agents and blueprints that are not files of a harness
24
+ folder. Examples: a registry of agents, or the agents of other machines.
25
+
26
+ **When.**
27
+
28
+ - `agents(workspaceDir)`: the node lists its agents, for `agent list`, for the
29
+ catalog and for `agentPeers`. An agent file with the same name hides a
30
+ directory agent.
31
+ - `lookup(ref)`: a run names an agent that no harness folder has. The
32
+ node asks each directory in order. The first answer that is not null wins.
33
+ - `agent(id, session)`: a session of an agent from this directory starts. The
34
+ answer gives its harness, egress and bootstrap. The node reads these
35
+ `bootstrap` keys: `systemPrompt`, `agentModel`, `provider`, `harness` and
36
+ `skills` (a list of `ResolvedSkill`).
37
+ - `blueprint(id, session)` and `blueprintFile(id, path, session)`: a session of
38
+ an agent from this directory calls `GET /orbital/blueprints/<id>` or
39
+ `GET /orbital/blueprints/<id>/file?path=<path>` on the node.
40
+ - `announce(catalog)`: once, after the node serves and its channels listen. The
41
+ catalog holds the teams, the agents, the live sessions and the started teams
42
+ of the node. A directory that other machines read uses it to publish this
43
+ node.
44
+
45
+ **Config.** `directory: <plugin>` or `directory: [<plugin>, ...]`. Without the
46
+ key, the node uses each plugin that provides a directory, in plugin order.
47
+
48
+ **Interface.**
49
+
50
+ ```ts
51
+ interface Directory {
52
+ agents(workspaceDir: string): Promise<DirectoryAgentSummary[]>;
53
+ lookup(ref: string): Promise<DirectoryAgentRef | null>;
54
+ agent(id: string, session: SessionInfo): Promise<ResolvedAgent | null>;
55
+ blueprint?(id: string, session: SessionInfo): Promise<BlueprintContent | null>;
56
+ blueprintFile?(id: string, path: string, session: SessionInfo): Promise<BlueprintFile | null>;
57
+ announce?(catalog: MachineCatalog): Promise<void>;
58
+ }
59
+ interface DirectoryAgentSummary { name: string; id: string | null; description: string | null }
60
+ interface DirectoryAgentRef { id: string; name: string }
61
+ interface ResolvedAgent { id: string; name: string; harness: Harness | null; bootstrap: Record<string, unknown>; egress: string[] }
62
+ interface ResolvedSkill { slug: string; name: string; description: string | null; loadMode?: string; contentBase64: string; files: Array<{ path: string; contentBase64: string }> }
63
+ interface BlueprintContent { id: string; contentBase64: string; files: Array<{ path: string; size: number; hash: string | null }> }
64
+ interface BlueprintFile { path: string; mimeType: string | null; size: number; contentBase64: string }
65
+ ```
66
+
67
+ **Remote methods.** `directory.agents {workspaceDir}`, `directory.lookup {ref}`,
68
+ `directory.agent {id, session}`,
69
+ `directory.blueprint {id, session}`, `directory.blueprintFile {id, path, session}`,
70
+ `directory.announce {catalog}`.
71
+
72
+ **Example.**
73
+
74
+ ```ts
75
+ const agents = [{ id: 'rev-1', name: 'reviewer', description: 'Reviews a pull request.', prompt: 'You review code.' }];
76
+
77
+ ports: {
78
+ directory: {
79
+ agents: async () => agents.map(({ id, name, description }) => ({ id, name, description })),
80
+ lookup: async (ref) => agents.find((one) => one.name === ref || one.id === ref) ?? null,
81
+ agent: async (id) => {
82
+ const found = agents.find((one) => one.id === id);
83
+ return found ? { id: found.id, name: found.name, harness: null, egress: [], bootstrap: { systemPrompt: found.prompt } } : null;
84
+ },
85
+ },
86
+ },
87
+ ```
88
+
89
+ The check: `agents()` answers `{ name, id, description }` items, and `lookup()`
90
+ of an unknown name answers null.
91
+
92
+ ## `placements`
93
+
94
+ **For.** Run a session somewhere other than this node: another machine, a
95
+ cloud service, or a node behind a channel. The key of the record is the
96
+ placement kind. A placement is `<kind>` or `<kind>:<name>`, such as
97
+ `lan:build-box`.
98
+
99
+ **When.** A new session gets a placement other than `local` from one of these:
100
+
101
+ - the member entry in `teams/<team>/CHARTER.md`: `members: [{ name: coder, placement: lan:build-box }]`;
102
+ - the `placement` parameter of `agentDispatch`;
103
+ - a `sessions.create` answer `{ kind: 'placement', placement }` (see `sessions`).
104
+
105
+ Then the node calls `endpoint(placement)`. It sends the A2A `SendMessage` of
106
+ the turn to the answer, and follows the task until it ends. A later turn of the
107
+ same session goes to the same placement.
108
+
109
+ - An `A2aEndpoint` answer: the node posts JSON-RPC to `url` with `headers`. A
110
+ node of orbital serves A2A at `<node url>/a2a` and takes its own secret as
111
+ `authorization: Bearer <token>`.
112
+ - A `ChannelRoute` answer: the node sends the turn on that channel to `to`.
113
+ - `decides: true`: the endpoint chooses the target itself. The node does not
114
+ put the placement in the message metadata (`orbital/placement`). With
115
+ `decides: false`, the node puts it there, so the far side can place the
116
+ session.
117
+
118
+ **Config.** `placement: { <kind>: <plugin> }`. Without an entry for a kind, the
119
+ node uses the first plugin that serves the kind. A placement kind that no
120
+ plugin serves refuses the session start.
121
+
122
+ **Interface.**
123
+
124
+ ```ts
125
+ interface Placement {
126
+ endpoint(placement: string): Promise<PlacementTarget>;
127
+ readonly decides?: boolean;
128
+ }
129
+ type PlacementTarget = A2aEndpoint | ChannelRoute;
130
+ interface A2aEndpoint { url: string; headers: Record<string, string> }
131
+ interface ChannelRoute { channel: string; config: Record<string, unknown>; to: Address }
132
+ ```
133
+
134
+ **Remote methods.** `placement.endpoint {placement}`. The SDK sends the call to
135
+ the placement whose key is the part before `:`.
136
+
137
+ **Example.**
138
+
139
+ ```ts
140
+ ports: {
141
+ placements: {
142
+ lan: {
143
+ decides: true,
144
+ endpoint: async (placement) => {
145
+ const name = placement.slice('lan:'.length);
146
+ const peer = peers.get(name);
147
+ if (!peer) throw new Error(`No peer ${name} on this network.`);
148
+ return { url: `${peer.url}/a2a`, headers: { authorization: `Bearer ${peer.token}` } };
149
+ },
150
+ },
151
+ },
152
+ },
153
+ ```
154
+
155
+ The check needs a fixture: `{"placements":{"lan":"lan:build-box"}}`. Without
156
+ it, the check skips.
157
+
158
+ ## `policy`
159
+
160
+ **For.** Allow, deny or ask before the node does an action.
161
+
162
+ **When.** Before each action of these kinds:
163
+
164
+ | `kind` | When | Fields |
165
+ |---|---|---|
166
+ | `tool.call` | A tool call of a session | `sessionId`, `tool`, `target`, `name`, `input` |
167
+ | `session.start` | A session starts | `agent`, `placement`, `harness`, `parentSessionId`, `workspaceDir` |
168
+ | `session.contribution` | A `sessions.open` hook asks for egress | `sessionId`, `plugin`, `egress` |
169
+ | `message.send` | One agent sends a message to another | `fromSessionId`, `toSessionId`, `rootSessionId`, `hops`, `at` |
170
+ | `model.use` | A model call, also `host.completeModel` | `sessionId`, `model`, `plugin` |
171
+ | `plugin.load` | A plugin starts (see the note below) | `plugin`, `network`, `secrets`, `models` |
172
+ | `channel.receive` | A message reaches the node from a channel or over the network (see the section below) | `channel`, `from`, `to`, `human`, `text`, `origin` |
173
+ | `action.submit` | An agent submits an action type | `sessionId`, `actionType`, `actor` |
174
+ | `mission.start` | A mission starts | `team`, `mission`, `plugin`, `event`, `key` |
175
+
176
+ Note: the node checks `plugin.load` while it starts the plugins, before any
177
+ policy engine runs. In this version no engine sees that action from a node.
178
+
179
+ ### `channel.receive`
180
+
181
+ The action gives an engine the text of a message and where it came from, so an
182
+ engine can govern each message that reaches the node over the network.
183
+
184
+ ```ts
185
+ type ChannelTrust = 'verified' | 'unverified';
186
+ type ChannelOrigin =
187
+ | { kind: 'local' }
188
+ | { kind: 'network'; peer: string; address: Address; trust: ChannelTrust };
189
+ type ChannelReceive = {
190
+ kind: 'channel.receive';
191
+ channel: string;
192
+ from: Address;
193
+ to: Address;
194
+ human: boolean;
195
+ text: string;
196
+ origin: ChannelOrigin;
197
+ };
198
+ ```
199
+
200
+ - `text` is the text of the message. A call with no text, such as a cancel or a
201
+ pause, gives a short phrase that names the operation, for example
202
+ `cancel the task t1`.
203
+ - `human` is true when the sender is a person.
204
+ - `origin.peer` is the name of the sending node when the sender address has an
205
+ `ade`. Else it is the name of the sender, or the name that the carrying
206
+ plugin gives.
207
+ - `origin.address` is the address of the sender.
208
+ - `origin.trust` is `verified` only when the channel declares
209
+ `authenticated: true`. Every other path gives `unverified`.
210
+ - `origin.kind` is `local` only for a message on a channel that declares
211
+ `authenticated: true` when the sender address names this node in `ade`. The
212
+ address of a message that an unauthenticated channel delivers is the word of
213
+ the sender, so the node never trusts it as local.
214
+ - A session of the node, a holder of the node secret and the CLI on the same
215
+ machine never reach this action. A request over HTTP reaches it when its
216
+ socket address is not a loopback address.
217
+
218
+ The node checks `channel.receive` on these paths, and each refusal gives the
219
+ reason to the sender:
220
+
221
+ | Path | The sender gets |
222
+ |---|---|
223
+ | `host.deliver`: work, a cancel, the answer of a person to a question | `DeliveryAnswer` with `accepted: false` and the `reason`. For refused work, the node also sends a reply over the same channel (see below) |
224
+ | `host.joinMission`, `replyMission`, `sayMission`, `pauseMission`, `resumeMission`, `askGiver` | an error with the reason |
225
+ | `host.a2a`, and a request to `/a2a` from a non-loopback address | a JSON-RPC error with the reason |
226
+
227
+ The answer of a remote node to a request of this node is not checked: this
228
+ node made the request.
229
+
230
+ When the node refuses work that a channel delivered, it also sends the sender a
231
+ reply envelope over the same channel. The reply is in the same thread, from the
232
+ receiver to the sender. Its message answers the request (`orbital/inReplyTo`)
233
+ with a status update of the state `TASK_STATE_REJECTED`. The text of the status
234
+ message is `refused: <reason>`. The node of the sender turns the reply into the
235
+ status of the task that its session reads, so the sender sees the request end
236
+ as rejected with the reason as its result. A refused cancel and a refused
237
+ answer of a person get no reply.
238
+
239
+ The node asks each engine in order. One `deny` refuses the action. An `ask`
240
+ waits for a person on the team's channel. An engine with
241
+ `deterministic: false`, such as a model, can only deny or ask: the node reads
242
+ its `allow` as no answer.
243
+
244
+ **Config.** `policy: [orbital, <plugin>, ...]`, in the order to ask. Each entry
245
+ is a plugin name or `{ plugin: <name>, ...settings }`. Without the key, the node
246
+ asks each plugin that provides a policy, in plugin order. `orbital` is the
247
+ built-in engine. It limits the messages between agents.
248
+
249
+ **Interface.**
250
+
251
+ ```ts
252
+ interface PolicyEngine {
253
+ readonly deterministic: boolean;
254
+ decide(action: PolicyAction): Promise<PolicyDecision>;
255
+ }
256
+ type PolicyDecision =
257
+ | { verdict: 'allow' }
258
+ | { verdict: 'deny'; reason: string; policy: string }
259
+ | { verdict: 'ask'; question: string; policy: string };
260
+ ```
261
+
262
+ **Remote methods.** `policy.decide {action}`.
263
+
264
+ **Example.**
265
+
266
+ ```ts
267
+ ports: {
268
+ policy: {
269
+ deterministic: true,
270
+ decide: async (action) => {
271
+ if (action.kind === 'tool.call' && action.tool === 'shell' && action.target.includes('git push')) {
272
+ return { verdict: 'deny', reason: 'Pushes go through review.', policy: 'push-guard' };
273
+ }
274
+ if (action.kind === 'session.start' && action.placement !== 'local') {
275
+ return { verdict: 'ask', question: `Start ${action.agent} on ${action.placement}?`, policy: 'push-guard' };
276
+ }
277
+ return { verdict: 'allow' };
278
+ },
279
+ },
280
+ },
281
+ ```
282
+
283
+ The check: each answer is `allow`, `deny` with a reason, or `ask` with a
284
+ question. For a model as the judge, see the policy recipe in
285
+ [recipes.md](recipes.md).
286
+
287
+ ## `telemetry`
288
+
289
+ **For.** Send the usage of each turn, spans and the events of the node to a
290
+ dashboard or a metrics system.
291
+
292
+ **When.**
293
+
294
+ - `usage(session, report)`: after each turn, with the tokens of each model. The
295
+ session gives `node`, `team`, `mission`, `agent` and `parentSessionId`.
296
+ - `span(span)`: for each span the node records, such as an action call.
297
+ - `event(event)`: for each event of the node, in order: `node.up`, `node.down`,
298
+ `session.started`, `session.ended`, `job.dispatched`, `job.ended`,
299
+ `mission.state`, `turn.started`, `turn.ended`. A `turn.ended` holds the usage
300
+ of the turn. A sub-agent of a lead whose type names a team member is a job of
301
+ that member, with `via` the lead.
302
+
303
+ Each event has `v` (`orbital.telemetry/v1`), `seq`, `id`, `at` and `node`.
304
+ `seq` goes up by one for each event of the node. The node never waits for a
305
+ sink. It keeps at most 1000 calls for each sink, drops the oldest past that,
306
+ and warns with the count. A call that takes more than 10 seconds counts as
307
+ failed.
308
+
309
+ To hold a live graph, take `host.snapshot()` once when the service starts. Then
310
+ apply only the events with a `seq` above its `sequence`. A gap in `seq` means
311
+ that the sink dropped events: take a new snapshot. See
312
+ [host-api.md](host-api.md#snapshot) and the dashboard recipe in
313
+ [recipes.md](recipes.md).
314
+
315
+ **Config.** `telemetry: <plugin>` or a list. Without the key, the node sends to
316
+ each plugin that provides telemetry.
317
+
318
+ **Interface.**
319
+
320
+ ```ts
321
+ interface TelemetrySink {
322
+ usage?(session: SessionInfo, report: UsageReport): Promise<void>;
323
+ span?(span: TelemetrySpan): Promise<void>;
324
+ event?(event: OrbitalEvent): Promise<void>;
325
+ }
326
+ interface UsageReport { harness: Harness; models: ModelUsage[] }
327
+ interface TelemetrySpan { name: string; start: number; end: number; attributes: Record<string, string | number | boolean>; status: 'ok' | 'error' }
328
+ ```
329
+
330
+ **Remote methods.** `telemetry.usage {session, report}`, `telemetry.span {span}`,
331
+ `telemetry.event {event}`.
332
+
333
+ **Example.**
334
+
335
+ ```ts
336
+ ports: {
337
+ telemetry: {
338
+ usage: async (session, report) => send({ node: session.node, team: session.team, mission: session.mission, models: report.models }),
339
+ event: async (event) => send(event),
340
+ },
341
+ },
342
+ ```
343
+
344
+ `orbital plugin new <name> --port telemetry` writes a sink that keeps the
345
+ graph. The JSON Schema of an event and of a snapshot are in the `schemas`
346
+ folder of `@kaiba-cloud/orbital`. `orbital telemetry watch` prints the
347
+ same events, and `orbital telemetry snapshot` prints the same snapshot.
348
+
349
+ ## `sessions`
350
+
351
+ **For.** Take part in the life of each session: choose where a new session
352
+ runs, give a session environment, commands, egress and prompt text, and receive
353
+ its events.
354
+
355
+ **When.**
356
+
357
+ - `create(start)`: a new session is about to start on this node, with no
358
+ placement chosen. Answer `{ kind: 'id', sessionId }` to give the session its
359
+ id: the plugin then owns the session. Answer `{ kind: 'placement', placement }`
360
+ to start it on a placement (see `placements`). Answer null to pass. The node
361
+ asks each plugin in plugin order. The first answer that is not null wins.
362
+ - `open(session)`: a turn of the session starts on this node. The answer adds to
363
+ the turn:
364
+ - `env`: variables for the harness. The first plugin that sets a name wins.
365
+ - `shims`: `name: command`. The node writes an executable `name` on the
366
+ turn's PATH that runs `command`.
367
+ - `egress`: hosts the sandbox lets the turn reach. A `session.contribution`
368
+ policy action decides it first.
369
+ - `promptBlock`: text the node adds to the system prompt.
370
+ - `sinks`: names of event sinks. The node then calls `deliver` for each one.
371
+ - `close(session)`: the turn ends, or `open` of a later plugin failed. The node
372
+ closes the plugins in reverse order.
373
+ - `deliver(sink, sessionId, events)`: after the node writes new events to the
374
+ log of a session that has the sink. `events` holds the events after the last
375
+ one the sink took. Answer the `sequence` of the last event you took. The node
376
+ sends the rest again on the next call.
377
+
378
+ **Config.** None. The node calls the hooks of every enabled plugin.
379
+
380
+ **Interface.**
381
+
382
+ ```ts
383
+ interface SessionHooks {
384
+ create?(start: SessionStartRequest): Promise<SessionStartAnswer | null>;
385
+ open?(session: SessionInfo): Promise<SessionContribution | null>;
386
+ close?(session: SessionInfo): Promise<void>;
387
+ deliver?(sink: string, sessionId: string, events: readonly SessionEvent[]): Promise<number>;
388
+ }
389
+ interface SessionStartRequest { agent: string; parentSessionId: string | null; team: string | null; definitionAgent: boolean; declaration: Record<string, unknown> | null; register: boolean }
390
+ type SessionStartAnswer = { kind: 'id'; sessionId: string } | { kind: 'placement'; placement: string };
391
+ interface SessionContribution { env?: Record<string, string>; shims?: Record<string, string>; egress?: string[]; promptBlock?: string; sinks?: string[] }
392
+ interface SessionInfo {
393
+ sessionId: string; node: string; agent: string; agentId: string | null; agentSource: string | null;
394
+ declaration: Record<string, unknown> | null; parentSessionId: string | null; workspaceDir: string;
395
+ harness: Harness; team: string | null; mission: string | null;
396
+ scratchpad: string | null; memory: string | null; missionScratchpad: string | null;
397
+ }
398
+ ```
399
+
400
+ **Remote methods.** `sessions.create {start}`, `sessions.open {session}`,
401
+ `sessions.close {session}`, `sessions.deliver {sink, sessionId, events}`.
402
+
403
+ **Example.**
404
+
405
+ ```ts
406
+ ports: {
407
+ sessions: {
408
+ open: async (session) => ({
409
+ env: { TICKET_SESSION: session.sessionId },
410
+ promptBlock: `You work for the team ${session.team ?? 'none'}.`,
411
+ sinks: ['audit'],
412
+ }),
413
+ deliver: async (_sink, sessionId, events) => {
414
+ await appendAudit(sessionId, events);
415
+ return events.at(-1)?.sequence ?? 0;
416
+ },
417
+ },
418
+ },
419
+ ```
420
+
421
+ The check opens a session and requires `env` values to be strings and
422
+ `egress` to be a list of hosts.
423
+
424
+ ## `tools`
425
+
426
+ **For.** Host tools that sessions of the node call, such as a search in a
427
+ company system.
428
+
429
+ **When.** A session of the node calls `POST <node url>/orbital/tools/<name>`
430
+ with its session token. The node first asks the `actions` port. Then it asks
431
+ each `tools` port in plugin order. The first answer that is not null wins. A
432
+ null answer means "not my tool".
433
+
434
+ The agent tools server of the node lists only the action tools. To make a
435
+ hosted tool known to an agent, tell the agent in a `sessions.open`
436
+ `promptBlock`, or give it a command through `shims`.
437
+
438
+ **Config.** None. The node uses the tools of every enabled plugin. The names in
439
+ `names` must not clash with an action tool: a clash refuses the call.
440
+
441
+ **Interface.**
442
+
443
+ ```ts
444
+ interface ToolGateway {
445
+ readonly names: readonly string[];
446
+ call(name: string, input: Record<string, unknown>, session: SessionInfo): Promise<HostedToolResult | null>;
447
+ }
448
+ interface HostedToolResult { text: string; isError: boolean; structuredContent?: JsonObject }
449
+ ```
450
+
451
+ **Remote methods.** `tools.call {name, input, session}`.
452
+
453
+ **Example.**
454
+
455
+ ```ts
456
+ ports: {
457
+ tools: {
458
+ names: ['ticket-get'],
459
+ call: async (name, input) => {
460
+ if (name !== 'ticket-get') return null;
461
+ const ticket = await fetchTicket(String(input.id));
462
+ return { text: JSON.stringify(ticket), isError: false, structuredContent: ticket };
463
+ },
464
+ },
465
+ },
466
+ ```
467
+
468
+ The check: `call()` of an unknown tool answers null.
469
+
470
+ ## `channels`
471
+
472
+ **For.** Carry A2A messages between people, teams and nodes: Slack, Discord,
473
+ email, a file. The key of the record is the channel id.
474
+
475
+ **When.**
476
+
477
+ - `listen(bindings)`: when the node starts serving, with the bindings of the
478
+ started teams and of `channels:` in `orbital.yaml`. A later call replaces the
479
+ bindings. After a restart of the plugin, the node calls it again.
480
+ - `send(envelope, binding)`: the node sends a message on the binding, such as an
481
+ answer or a question that waits for a person.
482
+
483
+ A message that arrives on the service goes to `host.deliver(inbound)`.
484
+
485
+ **Config.** A team names the channel in `teams/<team>/channels.yaml` as
486
+ `type: <id>`. The other keys of the entry are the binding's `config`. A node
487
+ names its own channels under `channels:` in `orbital.yaml`. The node uses the
488
+ first plugin that provides the id.
489
+
490
+ **Interface.**
491
+
492
+ ```ts
493
+ interface Channel {
494
+ readonly capabilities: ChannelCapabilities;
495
+ send(envelope: ChannelEnvelope, binding: ChannelBinding): Promise<{ ref: string; thread: string | null }>;
496
+ listen(bindings: readonly ChannelBinding[]): Promise<void>;
497
+ }
498
+ interface ChannelCapabilities { live: boolean; human: boolean; threads: boolean; durable: boolean; offline: boolean; authenticated: boolean }
499
+ interface ChannelBinding { id: string; channel: string; config: Record<string, unknown>; team: string | null; ade: string }
500
+ interface ChannelEnvelope { message: Message; from: Address; to: Address; thread: string | null }
501
+ interface Address { kind: 'agent' | 'team' | 'session' | 'person' | 'node'; name: string; ade?: string }
502
+ interface InboundDelivery { channel: string; bindingId: string; envelope: ChannelEnvelope }
503
+ ```
504
+
505
+ **Remote methods.** `channel.send {channel, envelope, binding}`,
506
+ `channel.listen {channel, bindings}`. `channel` is the channel id.
507
+
508
+ **Example.**
509
+
510
+ ```ts
511
+ const sent = new Map<string, { ref: string; thread: string | null }>();
512
+ let bindings: ChannelBinding[] = [];
513
+
514
+ ports: {
515
+ channels: {
516
+ discord: {
517
+ capabilities: { live: true, human: true, threads: true, durable: true, offline: false, authenticated: true },
518
+ send: async (envelope, binding) => {
519
+ const known = sent.get(envelope.message.messageId);
520
+ if (known) return known;
521
+ const ref = await post(binding.config.channel, envelope);
522
+ const answer = { ref, thread: envelope.thread ?? ref };
523
+ sent.set(envelope.message.messageId, answer);
524
+ return answer;
525
+ },
526
+ listen: async (given) => { bindings = [...given]; },
527
+ },
528
+ },
529
+ },
530
+ ```
531
+
532
+ - `capabilities`: `live` (it pushes), `human` (a person reads and writes),
533
+ `threads`, `durable`, `offline` (it works with no network outside the LAN),
534
+ `authenticated` (the channel proves who sent each message, so the node marks
535
+ its messages `verified`). Set `authenticated: false` when the channel only
536
+ carries the word of the sender, such as a shared folder.
537
+ - A second send of the same message id must answer the first ref and send
538
+ nothing.
539
+ - A message id must be stable: the node drops a second delivery of the same id.
540
+ - Put an answer in the thread of its question.
541
+ - Fixture for the send check:
542
+ `{"channels":{"discord":{"id":"node:0","channel":"discord","config":{},"team":null,"ade":"conformance"}}}`.
543
+
544
+ The Slack recipe in [recipes.md](recipes.md) adds the service that listens.
545
+
546
+ ## `store`
547
+
548
+ **For.** Keep session state off this machine, such as in R2 or S3.
549
+
550
+ **When.** The node reads a unit when a session resumes or a scratchpad is
551
+ restored. It writes the unit after the session changes. With `lease`, one ADE
552
+ at a time holds a unit.
553
+
554
+ - `history`: the run state of a session: the transcript, the event log and the
555
+ harness state. A resume on another machine reads it.
556
+ - `session` and `mission`: the scratchpads, as text files only. A transcript
557
+ never goes there.
558
+ - `temporary`: short-lived files.
559
+
560
+ **Config.** Only an explicit choice: `store: { history: [{ plugin: r2, bucket: orbital }] }`.
561
+ `settings` is the choice without `plugin`: here `{ bucket: 'orbital' }`. A store
562
+ whose `kinds` do not hold the kind refuses the start.
563
+
564
+ **Interface.**
565
+
566
+ ```ts
567
+ interface StateStore {
568
+ readonly kinds: readonly StateKind[];
569
+ list(ref: StoreRef): Promise<StoredEntry[] | null>;
570
+ read(ref: StoreRef, path: string): Promise<StoredFile | null>;
571
+ write(ref: StoreRef, files: readonly StoredFile[], removed: readonly string[]): Promise<void>;
572
+ lease?(ref: StoreRef, holder: string, ttlMs: number): Promise<StoreLease>;
573
+ release?(ref: StoreRef, holder: string): Promise<void>;
574
+ }
575
+ type StateKind = 'history' | 'session' | 'mission' | 'temporary';
576
+ interface StoreRef { kind: StateKind; id: string; unit: string; settings: Record<string, unknown> }
577
+ interface StoredEntry { path: string; hash: string; size: number }
578
+ interface StoredFile { path: string; contentBase64: string }
579
+ interface StoreLease { granted: boolean; holder: string; expiresAt: string }
580
+ ```
581
+
582
+ **Remote methods.** `store.list {ref}`, `store.read {ref, path}`,
583
+ `store.write {ref, files, removed}`, `store.lease {ref, holder, ttlMs}`,
584
+ `store.release {ref, holder}`.
585
+
586
+ **Example.**
587
+
588
+ ```ts
589
+ ports: {
590
+ store: {
591
+ kinds: ['history'],
592
+ list: async (ref) => /* [{ path, hash: SHA-256 hex, size }], or null when it holds no unit */,
593
+ read: async (ref, path) => /* { path, contentBase64 } or null */,
594
+ write: async (ref, files, removed) => { /* write files, delete the removed paths */ },
595
+ },
596
+ },
597
+ ```
598
+
599
+ - `unit` is the place of the unit, the same on every ADE: `sessions/<id>` for
600
+ history, `agents/<agent>/sessions/<id>` for a session scratchpad. Use it as
601
+ the key prefix.
602
+ - Paths use `/` between folders. Refuse a path that leaves the unit.
603
+ - Fixture: `{"store": <settings>}`. `orbital plugin new <name> --port store`
604
+ writes a store in a folder that passes every check.
605
+
606
+ ## `workspace`
607
+
608
+ **For.** Keep the working folder of a team or an agent off this machine. A
609
+ session's own (`ephemeral`) workspace is never saved.
610
+
611
+ **When.** `load(ref, dir)` when the node makes the workspace on a machine that
612
+ has none of it. `save(ref, dir, ignore)` after each turn.
613
+
614
+ **Config.** Only an explicit choice: `store: { workspace: [{ plugin: r2, bucket: orbital }] }`.
615
+
616
+ **Interface.**
617
+
618
+ ```ts
619
+ interface WorkspaceStore {
620
+ load(ref: WorkspaceRef, dir: string): Promise<boolean>;
621
+ save(ref: WorkspaceRef, dir: string, ignore: readonly string[]): Promise<void>;
622
+ }
623
+ type WorkspaceOwner = 'team' | 'agent' | 'session';
624
+ interface WorkspaceRef { project: string; owner: WorkspaceOwner; name: string; settings: Record<string, unknown> }
625
+ ```
626
+
627
+ **Remote methods.** `workspace.load {ref, dir}`, `workspace.save {ref, dir, ignore}`.
628
+
629
+ **Example.**
630
+
631
+ ```ts
632
+ ports: {
633
+ workspace: {
634
+ load: async (ref, dir) => /* fill dir; answer false when the store holds none */,
635
+ save: async (ref, dir, ignore) => { /* store dir, except the paths in ignore, such as .git */ },
636
+ },
637
+ },
638
+ ```
639
+
640
+ - The plugin runs on the node's machine, so it reads and writes `dir` itself.
641
+ - `project` is `<folder>-<hash>`, the same on every machine with the same
642
+ remote. `name` is the team (`<team>.<mission>` for mission work), the agent
643
+ or the session id. Use `<project>/<owner>/<name>` as the key prefix.
644
+ - A save is the whole workspace: a file removed from the workspace goes from the
645
+ store too.
646
+ - Fixture: `{"workspace": <settings>}`.
647
+
648
+ ## `harnesses`
649
+
650
+ **For.** Bring a harness to orbital: run turns on another agent program, such
651
+ as a CLI agent, and own the folder of that harness in a project (its agent
652
+ files, its `orbital.yaml`, and how it connects to orbital). One port does both:
653
+ it is the adapter of the harness. `claude` is the only harness built in;
654
+ every other harness, `pi` among them, is a plugin on this port.
655
+
656
+ **When.** A session's harness is one of the ids. For each turn, the node calls
657
+ `command(spec)`, writes the spec to a file, sets `ORBITAL_HARNESS_TURN` to its
658
+ path, and runs the command in the sandbox.
659
+
660
+ **Config.** None. An agent or a team member names the harness id. The node uses
661
+ the first plugin that brings the id. A harness with a `folder` is set up in a
662
+ project with `orbital install <id> --plugin <npm package|path>`.
663
+
664
+ **Interface.**
665
+
666
+ ```ts
667
+ interface HarnessProvider {
668
+ readonly harnesses: ReadonlyArray<{ id: string; description: string; callsModel?: boolean; browser?: boolean; folder?: HarnessFolder | null }>;
669
+ command(spec: HarnessTurnSpec): Promise<HarnessCommand>;
670
+ install?(request: HarnessInstallRequest): Promise<{ written: string[]; notes: string[] }>;
671
+ agents?(request: HarnessAgentsRequest): Promise<HarnessAgentFile[]>;
672
+ }
673
+ interface HarnessFolder {
674
+ folder: string; // '.foo': a dot and the name, at the root of the project
675
+ instructions?: string | null; // the harness's instructions file, such as FOO.md
676
+ agents?: string; // the folder of the agent files, default 'agents'
677
+ skills?: string | null; // the folder of the skills
678
+ agentFiles?: 'markdown' | 'plugin'; // markdown with frontmatter (default), or agents() reads them
679
+ agentHarness?: 'adapter' | 'node'; // an agent file that names no harness runs on this one (default), or on the node's default
680
+ }
681
+ interface HarnessInstallRequest { harness: string; workspaceDir: string; folder: string; program: string[]; force: boolean }
682
+ interface HarnessAgentsRequest { harness: string; workspaceDir: string; folder: string; dir: string }
683
+ interface HarnessAgentFile { name: string; description?: string | null; systemPrompt: string; model?: string | null; harness?: string | null }
684
+ interface HarnessCommand { command: string[]; env?: Record<string, string>; egress?: string[] }
685
+ interface HarnessTurnSpec {
686
+ harness: string; sessionId: string; prompt: string; systemPrompt: string; model: string | null;
687
+ workspaceDir: string; sessionDir: string; resume: boolean; env: Record<string, string>;
688
+ credentials: Array<{ provider: string; authType: string; payload: string }>;
689
+ tools: { command: string[]; env: Record<string, string> };
690
+ browser: { url: string; headers: Record<string, string> } | null;
691
+ scratchpad: string | null; memory: string | null; missionScratchpad: string | null;
692
+ }
693
+ ```
694
+
695
+ **Remote methods.** `harness.command {spec}`, `harness.install {request}`, `harness.agents {request}`.
696
+
697
+ **Example.**
698
+
699
+ ```ts
700
+ ports: {
701
+ harnesses: {
702
+ harnesses: [{ id: 'my-agent', description: 'My agent CLI.', callsModel: false, folder: { folder: '.my-agent', instructions: 'MY-AGENT.md' } }],
703
+ command: async () => ({
704
+ command: [process.execPath, fileURLToPath(new URL('turn.js', import.meta.url))],
705
+ env: {},
706
+ egress: ['api.openai.com'],
707
+ }),
708
+ install: async (request) => registerMcpServer(request),
709
+ },
710
+ },
711
+ ```
712
+
713
+ An id is `a-z`, `0-9` and `-`, at most 32. `callsModel` is true by default:
714
+ the node then needs a provider key for the agent's model and gives it in
715
+ `credentials`. Set `callsModel: false` for a program that uses its own login or
716
+ calls no model.
717
+
718
+ **The shared browser.** `browser` is false by default. A harness that sets
719
+ `browser: true` gets `spec.browser` in each turn: the URL of the shared browser
720
+ of the node, an MCP server over streamable HTTP, and the headers that name the
721
+ node token and the session. The harness then sends the browser calls of the
722
+ agent there, in place of the chrome-devtools server of its own config. A
723
+ harness that sets no `browser` gets `spec.browser: null` and has no browser
724
+ tools.
725
+
726
+ **The folder.** A harness with a `folder` owns that folder in a project, as
727
+ `claude` owns `.claude`: `orbital.yaml`, `teams/`, `missions/`, `policies/`,
728
+ `orbital/plugins/` and its agent files. `orbital-plugin.json` declares the same
729
+ folder under `harnesses`, so a project finds the adapter before the plugin
730
+ runs; the node refuses a plugin whose two declarations differ:
731
+
732
+ ```json
733
+ { "name": "my-agent", "command": ["node", "dist/plugin.js"],
734
+ "harnesses": [{ "id": "my-agent", "folder": ".my-agent", "instructions": "MY-AGENT.md" }] }
735
+ ```
736
+
737
+ `orbital install my-agent --plugin <npm package|path>` loads the adapter,
738
+ writes `.my-agent/orbital.yaml` with the plugin under `plugins:` (so a clone
739
+ finds it), and calls `install`. `install` connects the harness to orbital, for
740
+ example it registers the orbital MCP server in the harness's own config:
741
+ `request.program` is the command that runs orbital, and the server is
742
+ `[...program, 'mcp', '--attach', '--harness', <id>, '--agent', <id>]`. A second
743
+ `install` writes nothing. The orbital files of every harness folder that
744
+ an adapter knows load together; a team runs where its folder is.
745
+
746
+ The harnesses a node runs are `claude` and the ids of its harness plugins. A node refuses to start when a charter member or an agent names
747
+ a harness outside them, and names the ones it runs. A member runs on the
748
+ harness its charter names, also when its lead dispatches it.
749
+
750
+ The turn program writes one JSON object a line on stdout. Each `message` shows
751
+ live in `orbital up`, in the pane of the Claude Code mod and in the transcript
752
+ of the session; build its parts with `textPart`, `thinkingPart`,
753
+ `toolCallPart` and `toolResultPart` from `@kaiba-cloud/orbital-sdk`:
754
+
755
+ ```ts
756
+ const say = (role: 'ROLE_AGENT' | 'ROLE_USER', parts: Part[]) => line({ type: 'message', message: { messageId: randomUUID(), role, parts } });
757
+ say('ROLE_AGENT', [thinkingPart('I look at the workspace first.')]);
758
+ say('ROLE_AGENT', [toolCallPart({ id: 'c1', name: 'ls', input: { path: spec.workspaceDir } })]);
759
+ say('ROLE_USER', [toolResultPart({ callId: 'c1', name: 'ls', text: listing, isError: false })]);
760
+ say('ROLE_AGENT', [textPart(answer)]);
761
+ line({ type: 'usage', models: [{ provider: 'example', model: spec.harness, inputTokens: 12, outputTokens: 33 }] });
762
+ line({ type: 'result', text: answer });
763
+ ```
764
+
765
+ A turn with no `result` failed. `{"type":"error","message":"…"}` says why. A
766
+ line that is not JSON is the program's own log. `usage` reaches the telemetry
767
+ of the node as the tokens of the turn. `spec.resume` is true when the session
768
+ has turns already. A stop or a mission pause ends the process with SIGTERM.
769
+ `orbital plugin new <name> --port harness` writes a harness that does all of
770
+ this; recipe 5 runs it in a team.
771
+
772
+ The spec gives three folders, or null when the run has none: `scratchpad` (the
773
+ session's working documents), `memory` (the agent's memory, which outlives the
774
+ session) and `missionScratchpad` (the shared folder of the mission). Mapping
775
+ them is optional. The environment also has `$ORBITAL_SCRATCHPAD`,
776
+ `$ORBITAL_MEMORY` and `$ORBITAL_MISSION`. The sandbox lets the turn write them.
777
+
778
+ ## `panels`
779
+
780
+ **For.** Rows that a surface shows: `orbital panel`, `orbital up` and the TUI.
781
+ The key of the record is the panel id.
782
+
783
+ **When.** `read()` each time a surface shows the panel.
784
+
785
+ Two panel ids have a meaning to the node:
786
+
787
+ - `missions`: rows of missions of other nodes. The node merges them into its
788
+ mission list and uses them for claims. See the missions recipe in
789
+ [recipes.md](recipes.md).
790
+ - `peers`: rows with `node`, `address` and `trust`. `orbital up` prints a line
791
+ for each new peer.
792
+
793
+ **Config.** None. Every enabled plugin's panels show.
794
+
795
+ **Interface.**
796
+
797
+ ```ts
798
+ interface Panel {
799
+ readonly title: string;
800
+ readonly kind: PanelKind;
801
+ read(): Promise<PanelData>;
802
+ }
803
+ type PanelKind = 'list' | 'tree' | 'transcript' | 'form';
804
+ interface PanelData { columns: string[]; rows: Array<Record<string, string | number | boolean | null>>; note?: string }
805
+ ```
806
+
807
+ **Remote methods.** `panel.read {id}`.
808
+
809
+ **Example.**
810
+
811
+ ```ts
812
+ ports: {
813
+ panels: {
814
+ queue: {
815
+ title: 'discord queue',
816
+ kind: 'list',
817
+ read: async () => ({ columns: ['channel', 'waiting'], rows: [{ channel: '#bugs', waiting: 2 }], note: 'Counts refresh each read.' }),
818
+ },
819
+ },
820
+ },
821
+ ```
822
+
823
+ Each row key must be a column. Each value is a string, a number, a boolean or
824
+ null. `orbital panel <plugin>/<id>` shows it.
825
+
826
+ ## `actions`
827
+
828
+ **For.** Run the action types of the ontology. The action gateway of the node
829
+ lists them to agents as tools, and sends each call to this port.
830
+
831
+ **When.**
832
+
833
+ - `catalog(session)`: the agent tools server of a session lists its tools.
834
+ - `evaluate(request, session)`: a call of a tool with `readOnlyHint: true`. It
835
+ reads and changes nothing.
836
+ - `submit(request, session)`: a call of any other tool, after the
837
+ `action.submit` policy action. Answer `applied` with the edits, or `refused`
838
+ with findings.
839
+
840
+ The node checks the input against `inputSchema` before it calls the port.
841
+
842
+ **Config.** None. The node uses the first plugin that provides actions.
843
+
844
+ **Interface.**
845
+
846
+ ```ts
847
+ interface ActionsPort {
848
+ catalog(session: SessionInfo): Promise<ActionTool[]>;
849
+ submit(request: ActionRequest, session: SessionInfo): Promise<ActionResult>;
850
+ evaluate(request: ActionRequest, session: SessionInfo): Promise<FunctionResult>;
851
+ }
852
+ interface ActionTool { name: string; title: string; description: string; inputSchema: JsonObject; outputSchema: JsonObject; annotations: ActionToolAnnotations }
853
+ interface ActionToolAnnotations { title: string; readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean }
854
+ interface ActionRequest { name: string; input: JsonObject; actor: ActionActor | null }
855
+ interface ActionActor { agent: string; skills: readonly { slug: string; name: string }[]; relations: readonly { type: string; uid: string }[] }
856
+ interface ActionResult { verdict: 'applied' | 'refused'; edits: AppliedEdit[]; findings: ActionFinding[] }
857
+ interface AppliedEdit { edit: string; op: string; uid: string; path: string }
858
+ interface ActionFinding { rule: string; finding: string; path: string; message: string }
859
+ interface FunctionResult { result: Json }
860
+ ```
861
+
862
+ **Remote methods.** `actions.catalog {session}`, `actions.submit {request, session}`,
863
+ `actions.evaluate {request, session}`.
864
+
865
+ **Example.**
866
+
867
+ ```ts
868
+ ports: {
869
+ actions: {
870
+ catalog: async () => [],
871
+ submit: async (request) => ({
872
+ verdict: 'refused',
873
+ edits: [],
874
+ findings: [{ rule: 'action', finding: 'violation', path: '', message: `No action type ${request.name}.` }],
875
+ }),
876
+ evaluate: async (request) => { throw new Error(`No function ${request.name}.`); },
877
+ },
878
+ },
879
+ ```
880
+
881
+ The checks: each tool has a name, an object `inputSchema` and a boolean
882
+ `readOnlyHint`, and `submit()` of an unknown type answers `refused` with a
883
+ finding and no edit. `orbital plugin new <name> --port actions` writes this
884
+ port.
885
+
886
+ ## `workflows`
887
+
888
+ **For.** Run workflow files on an engine, such as BPMN or Serverless Workflow.
889
+
890
+ **When.** The node binds a workflow file to an engine by its suffix. The port
891
+ starts a run, sends it a signal, reads its state and cancels it. The node uses
892
+ the first plugin whose `engines` holds the engine.
893
+
894
+ **Config.** `workflowBindings:` in `orbital.yaml` binds a file suffix to an
895
+ engine name: `- { engine: <name>, suffix: .bpmn }`. The defaults bind
896
+ `.sw.yaml`, `.sw.json`, `.bpmn` and `.arazzo.yaml`.
897
+
898
+ **Interface.**
899
+
900
+ ```ts
901
+ interface WorkflowsPort {
902
+ readonly engines: readonly string[];
903
+ start(binding: WorkflowStart, input: JsonObject): Promise<WorkflowRun>;
904
+ signal(runId: string, name: string, payload: Json): Promise<void>;
905
+ status(runId: string): Promise<WorkflowRun | null>;
906
+ cancel(runId: string): Promise<WorkflowRun | null>;
907
+ }
908
+ interface WorkflowStart { engine: string; path: string; name: string }
909
+ type WorkflowRunState = 'running' | 'completed' | 'failed' | 'cancelled';
910
+ interface WorkflowRun { runId: string; engine: string; state: WorkflowRunState; output: Json }
911
+ ```
912
+
913
+ **Remote methods.** `workflows.start {binding, input}`,
914
+ `workflows.signal {runId, name, payload}`, `workflows.status {runId}`,
915
+ `workflows.cancel {runId}`.
916
+
917
+ **Example.**
918
+
919
+ ```ts
920
+ const runs = new Map<string, WorkflowRun>();
921
+
922
+ ports: {
923
+ workflows: {
924
+ engines: ['flow'],
925
+ start: async (binding, input) => {
926
+ const run: WorkflowRun = { runId: `flow-${runs.size + 1}`, engine: binding.engine, state: 'completed', output: input };
927
+ runs.set(run.runId, run);
928
+ return run;
929
+ },
930
+ signal: async () => undefined,
931
+ status: async (runId) => runs.get(runId) ?? null,
932
+ cancel: async (runId) => runs.get(runId) ?? null,
933
+ },
934
+ },
935
+ ```
936
+
937
+ The check: `engines` is a list of names, and `status()` and `cancel()` of an
938
+ unknown run answer null.
939
+
940
+ ## `reasoner`
941
+
942
+ **For.** Reason over an OWL ontology in Turtle: extract a module, classify,
943
+ align two ontologies, and check consistency.
944
+
945
+ **When.** A caller of the node's port registry asks the first plugin that
946
+ provides a reasoner. Each answer is three-valued: `true`, `false` or
947
+ `'unknown'`. `unchecked` lists what the reasoner did not check. A method the
948
+ plugin cannot run answers `'unknown'` with an `unsupported` message.
949
+
950
+ **Config.** None. The node uses the first plugin that provides a reasoner.
951
+
952
+ **Interface.**
953
+
954
+ ```ts
955
+ interface ReasonerPort {
956
+ extractModule(request: ExtractModuleRequest): Promise<ExtractModuleAnswer>;
957
+ classify(request: ClassifyRequest): Promise<ClassifyAnswer>;
958
+ align(request: AlignRequest): Promise<AlignAnswer>;
959
+ checkConsistency(request: ConsistencyRequest): Promise<ConsistencyAnswer>;
960
+ }
961
+ interface ReasonerAnswer { answer: true | false | 'unknown'; unchecked: string[]; unsupported?: string }
962
+ interface ExtractModuleRequest { ontology: string; signature: string[]; method: 'star' | 'bot' | 'top' }
963
+ interface ExtractModuleAnswer extends ReasonerAnswer { module: string }
964
+ interface ClassifyRequest { ontology: string }
965
+ interface ClassifyAnswer extends ReasonerAnswer { subclasses: [string, string][] }
966
+ interface AlignRequest { source: string; target: string }
967
+ interface AlignAnswer extends ReasonerAnswer { candidates: Array<{ subject: string; predicate: AlignmentPredicate; object: string; confidence: number }> }
968
+ interface ConsistencyRequest { ontology: string }
969
+ interface ConsistencyAnswer extends ReasonerAnswer { unsatisfiable: string[] }
970
+ ```
971
+
972
+ **Remote methods.** `reasoner.extractModule {request}`, `reasoner.classify {request}`,
973
+ `reasoner.align {request}`, `reasoner.checkConsistency {request}`.
974
+
975
+ **Example.**
976
+
977
+ ```ts
978
+ const unsupported = { answer: 'unknown' as const, unchecked: [], unsupported: 'This reasoner only checks consistency.' };
979
+
980
+ ports: {
981
+ reasoner: {
982
+ extractModule: async () => ({ ...unsupported, module: '' }),
983
+ classify: async () => ({ ...unsupported, subclasses: [] }),
984
+ align: async () => ({ ...unsupported, candidates: [] }),
985
+ checkConsistency: async ({ ontology }) => ({ answer: true, unchecked: [], unsatisfiable: [] }),
986
+ },
987
+ },
988
+ ```
989
+
990
+ The check calls each method on a small ontology. See
991
+ `plugins/reasoner-open-ontologies` in the source repository.
992
+
993
+ ## `missions`
994
+
995
+ **For.** Carry the work on a mission between nodes. One node runs the lead of
996
+ the mission. A person or an agent on another node gives the mission, answers
997
+ its questions, talks to its lead, pauses it or resumes it. This is the port a
998
+ LAN or cross-machine plugin implements.
999
+
1000
+ **When.** The node calls the port only for a mission that it does not hold
1001
+ itself. It asks each plugin with the port, in plugin order. The first answer
1002
+ that is not null (or true) wins.
1003
+
1004
+ - `join(request)`: an attached session on this node joins a mission as its
1005
+ giver (`missionJoin`). `request.giver` is `{ node, sessionId }` of this node.
1006
+ - `say(request)`: a person or a session talks to the lead of a mission
1007
+ (`mission say`, `missionSay`). The far node gives the words to the lead: in
1008
+ its running turn, as its next turn, or as the answer to its question. When
1009
+ no plugin answers, the node tries `reply` next.
1010
+ - `reply(request)`: a person or a session answers the question of a waiting
1011
+ mission (`mission reply`, `missionReply`).
1012
+ - `pause(request)`, `resume(request)`: `mission pause`, `mission resume`.
1013
+ - `ask(question)`: the lead of a mission on this node waits with a question,
1014
+ and the giver is a session of another node. Answer true when you carried the
1015
+ question to that node. When no plugin answers true, the node warns.
1016
+
1017
+ Answer null (or false) when you do not know the mission or the node. Else
1018
+ answer what the far node gave back: a `MissionRecord`, or a `Said` for `say`.
1019
+
1020
+ **The far side.** The plugin on the far node takes the request on its own
1021
+ transport, then calls the host API of its node:
1022
+
1023
+ | Near node calls the port | The plugin on the far node calls |
1024
+ |---|---|
1025
+ | `join(request)` | `host.joinMission(request)` |
1026
+ | `say(request)` | `host.sayMission(request)` |
1027
+ | `reply(request)` | `host.replyMission(request)` |
1028
+ | `pause(request)` | `host.pauseMission(request)` |
1029
+ | `resume(request)` | `host.resumeMission(request)` |
1030
+ | `ask(question)` | `host.askGiver(question)` (true when delivered) |
1031
+
1032
+ The far node does not relay a request again. To find which node holds a
1033
+ mission, read `host.missions()` on each node: it gives the missions of that
1034
+ node only. To show the missions of other nodes in the mission list, and for
1035
+ claims, also give a `missions` panel (see the recipe).
1036
+
1037
+ **Config.** None. The node uses every enabled plugin with the port.
1038
+
1039
+ **Interface.**
1040
+
1041
+ ```ts
1042
+ interface MissionRelayPort {
1043
+ join(request: RelayJoin): Promise<MissionRecord | null>;
1044
+ reply(request: RelayReply): Promise<MissionRecord | null>;
1045
+ pause(request: RelayControl): Promise<MissionRecord | null>;
1046
+ resume(request: RelayControl): Promise<MissionRecord | null>;
1047
+ ask(question: MissionQuestion): Promise<boolean>;
1048
+ say(request: RelayMessage): Promise<Said | null>;
1049
+ }
1050
+ interface RemoteGiver { node: string; sessionId: string }
1051
+ interface RelayJoin { missionId: string; giver: RemoteGiver }
1052
+ interface RelayReply { missionId: string; text: string; by: string }
1053
+ interface RelayControl { missionId: string; by: string }
1054
+ interface RelayMessage { missionId: string; text: string; by: string }
1055
+ interface Said { sessionId: string; missionId: string | null; delivery: 'reply' | 'steer' | 'turn' }
1056
+ interface MissionQuestion { missionId: string; node: string; lead: string | null; leadSessionId: string; question: string; giver: RemoteGiver }
1057
+ interface MissionRecord {
1058
+ missionId: string; team: string | null; node: string; file: string | null;
1059
+ state: 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed';
1060
+ declared: boolean; givenBy: string | null; brief: string | null; sessionId: string | null; taskId: string | null;
1061
+ source: string | null; result: string | null; question: string | null; giver: string | null; lead: string | null;
1062
+ lastActivity: string | null; claim: string | null; claimedAt: string | null; stale: boolean; updatedAt: string | null;
1063
+ history: Array<{ state: MissionState; at: string; by: string; note: string | null }>;
1064
+ }
1065
+ ```
1066
+
1067
+ A mission id is lowercase letters, digits and hyphens, at most 63.
1068
+
1069
+ **Remote methods.** `missions.join {request}`, `missions.reply {request}`,
1070
+ `missions.pause {request}`, `missions.resume {request}`, `missions.ask {question}`,
1071
+ `missions.say {request}`.
1072
+
1073
+ **Example.** `peer` is your transport to the same plugin on the other nodes.
1074
+
1075
+ ```ts
1076
+ ports: {
1077
+ missions: {
1078
+ join: async (request) => peer.ask('join', request),
1079
+ reply: async (request) => peer.ask('reply', request),
1080
+ pause: async (request) => peer.ask('pause', request),
1081
+ resume: async (request) => peer.ask('resume', request),
1082
+ ask: async (question) => (await peer.send(question.giver.node, 'ask', question)) === true,
1083
+ say: async (request) => peer.ask('say', request),
1084
+ },
1085
+ },
1086
+ ```
1087
+
1088
+ `peer.ask` sends to each known node and answers the first answer, or null.
1089
+ The full plugin, with the far side, is the first recipe in
1090
+ [recipes.md](recipes.md). No conformance check exists for this port yet, so
1091
+ test it with two nodes.
1092
+
1093
+ ## `commands`
1094
+
1095
+ `commands` sits beside `ports`, not in it. A person runs a command with
1096
+ `orbital plugin run <plugin> <command> -- <args>`. The node prints the answer.
1097
+
1098
+ ```ts
1099
+ commands: {
1100
+ whoami: {
1101
+ summary: 'Name the account this plugin posts as.',
1102
+ run: async (args) => `bot in ${args[0] ?? 'the default guild'}`,
1103
+ },
1104
+ },
1105
+ ```
1106
+
1107
+ **Remote method.** `command.run {name, args}`, answered with `{ output }`.
1108
+
1109
+ ## `service`
1110
+
1111
+ `service` sits beside `ports`, not in it. It is background work while the node
1112
+ serves: a socket that listens, a poll, a timer.
1113
+
1114
+ ```ts
1115
+ service: {
1116
+ ends: false,
1117
+ start: async ({ host, signal }) => {
1118
+ const timer = setInterval(() => void poll(host), 5000);
1119
+ signal.addEventListener('abort', () => clearInterval(timer));
1120
+ return { stop: async () => clearInterval(timer) };
1121
+ },
1122
+ },
1123
+ ```
1124
+
1125
+ - The node starts the service after every plugin is ready and the node serves,
1126
+ so `host` is ready.
1127
+ - With `ends: true`, return `done`, a promise that resolves when the work ends,
1128
+ such as a queue served once. The node stops when every `done` resolves.
1129
+ - After a restart of the plugin, the node starts the service again.
1130
+
1131
+ **Remote methods.** `service.start`, `service.stop`; the plugin sends
1132
+ `service.done {ok, error?}` when `done` settles.