@kaiba-cloud/orbital 0.0.0-stage → 0.0.1

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,227 @@
1
+ ---
2
+ name: orbital-plugin-builder
3
+ description: Use when building, changing or debugging any orbital plugin or port for the `orbital` CLI (also `atom orbital`) — a channel (Slack, Discord, email), a store or workspace store, a harness, a panel, a policy engine, a telemetry sink or live dashboard (snapshot and events), a directory of agents, placements on other machines, session hooks, hosted tools, actions, workflows, a reasoner, a missions relay for cross-machine or LAN work, a plugin command or a background service — or when a plugin uses the host API, writes orbital-plugin.json or permissions, fails `orbital plugin check`, is refused at start, or must be built, added, tested or published to npm or a marketplace.
4
+ ---
5
+
6
+ # Build an orbital plugin
7
+
8
+ A plugin gives a node one or more ports. It runs in its own process and speaks
9
+ JSON-RPC on stdio. `@kaiba-cloud/orbital-sdk` does the protocol: you write one
10
+ plain object for each port.
11
+
12
+ ## References
13
+
14
+ | File | Read it for |
15
+ |---|---|
16
+ | [references/ports.md](references/ports.md) | Each port: what it is for, when the node calls it, the config, the interface, the remote methods, an example. |
17
+ | [references/host-api.md](references/host-api.md) | Each method of the host API: `submit`, `deliver`, `completeModel`, `missions`, `snapshot` and the others. |
18
+ | [references/manifest.md](references/manifest.md) | `orbital-plugin.json`, the `config.yaml` entry, the manifest, permissions, secrets, `context.init`. |
19
+ | [references/recipes.md](references/recipes.md) | Whole plugins: a cross-machine missions relay with a directory and placements, a telemetry dashboard, a Slack channel, a policy engine. |
20
+
21
+ ## Ports
22
+
23
+ | Port | Key | Use | Section |
24
+ |---|---|---|---|
25
+ | directory | `ports.directory` | agents, skills and blueprints that are not definition files | [directory](references/ports.md#directory) |
26
+ | placements | `ports.placements: { <kind>: Placement }` | run a session on another machine or service | [placements](references/ports.md#placements) |
27
+ | policy | `ports.policy` | allow, deny or ask before an action | [policy](references/ports.md#policy) |
28
+ | telemetry | `ports.telemetry` | usage, spans and node events, for a live dashboard | [telemetry](references/ports.md#telemetry) |
29
+ | sessions | `ports.sessions` | choose where a session runs; add env, commands, egress, prompt text; receive its events | [sessions](references/ports.md#sessions) |
30
+ | tools | `ports.tools` | tools that sessions call on the node | [tools](references/ports.md#tools) |
31
+ | channels | `ports.channels: { <id>: Channel }` | send messages and listen for them | [channels](references/ports.md#channels) |
32
+ | store | `ports.store` | session history and scratchpads off this machine | [store](references/ports.md#store) |
33
+ | workspace | `ports.workspace` | the workspace of a team or an agent off this machine | [workspace](references/ports.md#workspace) |
34
+ | harnesses | `ports.harnesses` | run turns on another agent program | [harnesses](references/ports.md#harnesses) |
35
+ | panels | `ports.panels: { <id>: Panel }` | rows a surface shows, and the `missions` and `peers` panels | [panels](references/ports.md#panels) |
36
+ | actions | `ports.actions` | run the action types of the ontology | [actions](references/ports.md#actions) |
37
+ | workflows | `ports.workflows` | run workflow files on an engine | [workflows](references/ports.md#workflows) |
38
+ | reasoner | `ports.reasoner` | reason over an OWL ontology | [reasoner](references/ports.md#reasoner) |
39
+ | missions | `ports.missions` | join, say, reply, pause, resume and ask on a mission of another node | [missions](references/ports.md#missions) |
40
+ | commands | `commands` (beside `ports`) | `orbital plugin run <plugin> <command>` | [commands](references/ports.md#commands) |
41
+ | service | `service` (beside `ports`) | background work while the node serves | [service](references/ports.md#service) |
42
+
43
+ ## Workflow
44
+
45
+ ### 1. New
46
+
47
+ ```bash
48
+ orbital plugin new <name> --port <port>
49
+ ```
50
+
51
+ `--port` takes `channel`, `store`, `workspace`, `harness`, `panel`, `policy`,
52
+ `telemetry`, `actions`, `workflows` or `command`. For a port with no template
53
+ (`directory`, `placements`, `sessions`, `tools`, `reasoner`, `missions`), start
54
+ with `--port panel`, then replace the ports in `src/plugin.ts`. One plugin can
55
+ give many ports.
56
+
57
+ The folder:
58
+
59
+ ```
60
+ orbital-plugin.json how the node starts it: {"name","command":["node","dist/plugin.js"]}
61
+ package.json the SDK and esbuild, as dev dependencies only
62
+ scripts/build.mjs src/main.ts to dist/plugin.js, one bundled file
63
+ src/plugin.ts the plugin: name, version, permissions, ports
64
+ src/main.ts servePlugin(plugin)
65
+ test/conformance.test.ts runConformance on the ports, and the plugin's own checks
66
+ fixtures.json what some checks need: a binding to send on, a store's settings
67
+ ```
68
+
69
+ Then `npm install`. If npm does not have `@kaiba-cloud/orbital-sdk`, install it
70
+ from the tarball of the orbital release. It is the only package a plugin needs:
71
+
72
+ ```bash
73
+ npm install ./kaiba-cloud-orbital-sdk-<v>.tgz
74
+ ```
75
+
76
+ ### 2. Implement
77
+
78
+ ```ts
79
+ import type { PluginContext, PluginImplementation } from '@kaiba-cloud/orbital-sdk';
80
+
81
+ export function plugin(context: PluginContext): PluginImplementation {
82
+ return {
83
+ name: 'discord',
84
+ version: '0.1.0',
85
+ permissions: { network: ['discord.com'], secrets: ['discord.token'], models: [] },
86
+ ports: { /* one key for each port */ },
87
+ };
88
+ }
89
+ ```
90
+
91
+ - `name` must match `orbital-plugin.json` and the key in `config.yaml`.
92
+ - The SDK builds the manifest from this object. `provides` comes from the ports
93
+ you give.
94
+ - Validate `context.init.config` at the start. Throw an error that names the
95
+ bad key.
96
+ - Read secrets with `context.secret(name)` when you need them, not at start.
97
+ - Call the node with `context.host()` inside a method, or with `host` in the
98
+ service. See [host-api.md](references/host-api.md).
99
+ - Never write to stdout: it is the protocol. Use `context.log(level, message)`.
100
+ A line on stderr reaches the node's log as a warning.
101
+
102
+ ### 3. Test
103
+
104
+ ```bash
105
+ npm test # build, then the conformance checks and your own checks
106
+ ```
107
+
108
+ - The conformance checks are the contract of each port. A check that needs a
109
+ value only you know skips. The skip names the fixture: `channels.<id>`,
110
+ `store`, `workspace`, `placements.<kind>`, `harness`.
111
+ - The `policy` and `command` templates add one check of their own. When you
112
+ replace `src/plugin.ts`, replace or delete that check.
113
+ - Test your own logic next to the checks: call the port methods with a fake
114
+ `PluginContext`, as the template's test does.
115
+ - For a test against a real node, start the plugin with `startRemotePlugin`
116
+ and a node with `runNode`, as `plugins/channel-slack/__tests__` does in the
117
+ source repository.
118
+
119
+ ### 4. Check through the real protocol
120
+
121
+ In a project with a definition (`orbital init`):
122
+
123
+ ```bash
124
+ orbital plugin add ./<name>
125
+ orbital plugin check <name> --fixtures <name>/fixtures.json
126
+ ```
127
+
128
+ `plugin add` prints the permissions, and names a folder in `config.yaml` by its
129
+ path. It does not copy it. Rebuild, and the next start of the node uses the new
130
+ build. `plugin check` starts the built plugin with the config of its
131
+ `config.yaml` entry and the secrets of `orbital plugin secret set`.
132
+
133
+ ### 5. Add to the config
134
+
135
+ The node's file is `config.yaml` in a definition folder, or
136
+ `.claude/orbital.yaml` in a project where `orbital init` found a `.claude`
137
+ folder. This skill says `config.yaml` for both.
138
+
139
+ ```yaml
140
+ plugins:
141
+ discord:
142
+ source: ./plugins/discord
143
+ config:
144
+ guild: "1234"
145
+ ```
146
+
147
+ Some ports need a choice too: `directory:`, `placement:`, `policy:`,
148
+ `telemetry:`, `store:`. Each section of [ports.md](references/ports.md) says
149
+ how. Then `orbital node start`.
150
+
151
+ ### 6. Publish
152
+
153
+ - **npm.** Publish `orbital-plugin.json` and `dist/`. Name the package so that
154
+ its last part is the plugin's name, such as `@acme/discord`.
155
+ `orbital plugin add npm:@acme/discord` names the plugin by that part.
156
+ - **A marketplace.** A Git repository with a `marketplace.json`. A person adds
157
+ it with `orbital plugin marketplace add <git-url>`, then runs
158
+ `orbital plugin add discord@<marketplace>`.
159
+
160
+ ```json
161
+ {
162
+ "name": "acme",
163
+ "plugins": [
164
+ { "name": "discord", "description": "Discord channels for teams.", "source": "npm:@acme/discord" },
165
+ { "name": "vault", "description": "A store in Vault.", "source": "./plugins/vault" }
166
+ ]
167
+ }
168
+ ```
169
+
170
+ A `source` is a folder of the repository, `npm:<package>` or `git:<url>`. A
171
+ folder must hold a built `dist/`. Keep every dependency a dev dependency: the
172
+ bundle holds them.
173
+
174
+ ## How a plugin runs
175
+
176
+ ```mermaid
177
+ sequenceDiagram
178
+ participant N as node
179
+ participant P as plugin process
180
+ N->>P: initialize {config, folder, node}
181
+ P-->>N: manifest
182
+ N->>P: host.endpoint {url, token}
183
+ N->>P: channel.listen, service.start
184
+ N->>P: port calls: channel.send, policy.decide, missions.join, ...
185
+ P->>N: host.secret, host.log (stdio)
186
+ P->>N: host API over HTTP: deliver, submit, snapshot, ...
187
+ N->>P: shutdown
188
+ ```
189
+
190
+ - The node starts the command in `orbital-plugin.json`, from the plugin's
191
+ folder. A source `exec:<command> <args>` starts a program on PATH instead.
192
+ - Only the built-in plugins run in the node's process: `orbital` (policy),
193
+ `local` and `static` (directories), `git` (a channel and a store), `folder` (a store and a workspace store).
194
+ - The node restarts a plugin that stops, with backoff, up to five times. It
195
+ then calls `channel.listen` and `service.start` again.
196
+
197
+ ## When something fails
198
+
199
+ Run `orbital plugin check <name>` first. A plugin that the node refuses at
200
+ start stops the start with "A plugin did not start: …".
201
+
202
+ | Symptom | Cause |
203
+ |---|---|
204
+ | "is X, and config.yaml names it Y" | The manifest `name` differs from the `config.yaml` key. |
205
+ | "exited (code N) before it answered" | It threw at start, or it wrote to stdout. Read the node's log for its stderr. |
206
+ | "did not declare the secret" | Add the name to `permissions.secrets`. |
207
+ | "declares the variables [...] as secrets, and its orbital-plugin.json declares env: [...]" | Make the secrets in variable form and `env` the same. |
208
+ | Refused with `offline: true` | It declares a network host. |
209
+ | "chooses the plugin X, and this host has no plugin of that name" | A port choice in `config.yaml` names a plugin that `plugins:` does not have. |
210
+ | "chooses X for the Y port, and that plugin provides none" | The plugin does not give that port, or the manifest leaves it out. |
211
+ | "No plugin of this node serves the placement" | No plugin has the placement kind in `ports.placements`. |
212
+ | A send check fails with another ref | A second send of a message id must answer the first ref and send nothing. |
213
+ | "ports must be orbital/v1alpha1" | The SDK is older or newer than the node. Build with the SDK of the node's version. |
214
+ | "The node has not given this plugin its endpoint yet." | `context.host()` ran in the factory. Call it inside a method or the service. |
215
+ | `npm test` fails on `hello` or `rm -rf` | The template's own check still tests the template. Replace it. |
216
+
217
+ ## Worked examples
218
+
219
+ In the source repository of orbital:
220
+
221
+ - `plugins/channel-slack`: a channel with a Socket Mode service, `host.deliver`, idempotent send, secrets, a panel and a command.
222
+ - `plugins/policy-judge`: a policy that calls a model through `host.completeModel`.
223
+ - `plugins/store-object`: a store in an object store.
224
+ - `plugins/reasoner-open-ontologies`: a reasoner.
225
+ - `packages/orbital/src/core/node/static-directory.ts`: a directory, a `sessions.create` hook and a placement that send `<agent>@<node>` to another node.
226
+ - `packages/orbital/__tests__/core/fixtures/echo-plugin`: session hooks, a directory, a hosted tool, a panel, a service that submits a turn, and a command.
227
+ - `packages/orbital/__tests__/core/fixtures/echo-harness`: a harness and its turn program.
@@ -0,0 +1,226 @@
1
+ # The host API
2
+
3
+ The host API is the node, as a plugin sees it. The type is `HostApi` from
4
+ `@kaiba-cloud/orbital-sdk`.
5
+
6
+ ## How to get it
7
+
8
+ - In a service: `start({ host, signal })` gives it.
9
+ - Anywhere else: `context.host()`. It throws until the node gives the plugin
10
+ its endpoint. The node gives it before the first port call and before the
11
+ service starts, so call `context.host()` inside a method, never in the
12
+ factory.
13
+
14
+ The plugin reaches the node over HTTP, on `127.0.0.1`, with a token that the
15
+ node made for this plugin only. The node knows the caller by that token: a
16
+ `submit` names the plugin as the one that placed the turn, and
17
+ `completeModel` checks the plugin's `permissions.models`.
18
+
19
+ Each method fails with an error that holds the HTTP status and the node's
20
+ message. Catch it where a failure must not stop the plugin.
21
+
22
+ ## Methods
23
+
24
+ | Method | Answer | Use |
25
+ |---|---|---|
26
+ | `submit(turn)` | `Task` | Start a turn: a new session, or the next turn of one. |
27
+ | `stopSession(sessionId)` | nothing | Stop a session and its running turn. |
28
+ | `status()` | `HostStatus` | Read how busy the node is. |
29
+ | `settled(orFollowing)` | nothing | Wait until the node has no running work. |
30
+ | `flush()` | nothing | Wait until the node delivers the pending session events to each sink. |
31
+ | `liveSessions()` | `LiveSession[]` | Read the sessions the node runs now. |
32
+ | `catalog()` | `MachineCatalog` | Read the teams, agents and sessions the node offers. |
33
+ | `readLog(sessionId)` | `SessionEvent[]` or null | Read the event log of a session. |
34
+ | `watch(sessionId, listener)` | a stop function | Follow the new events of a session. |
35
+ | `a2a(body)` | the JSON-RPC answer | Call the node's A2A server. |
36
+ | `deliver(inbound)` | `DeliveryAnswer` | Give the node a message that a channel received. |
37
+ | `completeModel(request, signal?)` | `ModelCompletion` | Make one model call on the person's keys. |
38
+ | `startMission(start)` | `MissionStarted` | Start a mission from an event. |
39
+ | `missions()` | `MissionRecord[]` | Read the missions of this node. |
40
+ | `joinMission(request)` | `MissionRecord` | Far side of `missions.join`. |
41
+ | `replyMission(request)` | `MissionRecord` | Far side of `missions.reply`. |
42
+ | `sayMission(request)` | `Said` | Far side of `missions.say`. |
43
+ | `pauseMission(request)` | `MissionRecord` | Far side of `missions.pause`. |
44
+ | `resumeMission(request)` | `MissionRecord` | Far side of `missions.resume`. |
45
+ | `askGiver(question)` | `boolean` | Far side of `missions.ask`. |
46
+ | `snapshot()` | `TelemetrySnapshot` | Read the telemetry graph of the node now. |
47
+
48
+ ### submit
49
+
50
+ ```ts
51
+ interface HostTurn {
52
+ sessionId?: string;
53
+ taskId?: string;
54
+ message: Message;
55
+ start?: { agent: string; harness?: string; workspaceDir?: string; model?: string | null; parentSessionId?: string | null; team?: string | null; mission?: string | null };
56
+ }
57
+ ```
58
+
59
+ - With `start` and no `sessionId`, the node starts a new session of `agent`.
60
+ Name a team as the agent to start its lead.
61
+ - With `sessionId`, the node gives the session its next turn.
62
+ - The answer is the A2A `Task`. `task.contextId` is the session id.
63
+
64
+ ```ts
65
+ const task = await host.submit({
66
+ message: { messageId: randomUUID(), role: 'ROLE_USER', parts: [{ text: 'Triage the new issues.' }] },
67
+ start: { agent: 'triage' },
68
+ });
69
+ ```
70
+
71
+ ### stopSession
72
+
73
+ `stopSession(sessionId)` stops the session's running turn and its tasks.
74
+
75
+ ### status
76
+
77
+ ```ts
78
+ interface HostStatus { busy: number; idle: boolean; running: string[]; followingRemote: boolean }
79
+ ```
80
+
81
+ `running` lists the sessions with a working task. `followingRemote` is true
82
+ while the node follows a session on another placement.
83
+
84
+ ### settled
85
+
86
+ `settled(false)` resolves when the node has no running work. `settled(true)`
87
+ also resolves when the only work left is a remote session that the node
88
+ follows. A service with `ends: true` can return `{ done: host.settled(false) }`.
89
+
90
+ ### flush
91
+
92
+ `flush()` resolves when the node gave each `sessions.deliver` sink the events it has not taken yet.
93
+
94
+ ### liveSessions
95
+
96
+ ```ts
97
+ interface LiveSession {
98
+ id: string; agent: string; harness: string; parentSessionId: string | null; status: string; isRunning: boolean;
99
+ team: string | null; mission: string | null; since: string | null; doing: string | null; lastActivityAt: string | null;
100
+ background: BackgroundTask[];
101
+ }
102
+ ```
103
+
104
+ ### catalog
105
+
106
+ ```ts
107
+ interface MachineCatalog { teams: CatalogTeam[]; agents: CatalogAgent[]; sessions: LiveSession[]; started: string[] }
108
+ ```
109
+
110
+ The node gives the same catalog to `directory.announce`.
111
+
112
+ ### readLog and watch
113
+
114
+ `readLog(sessionId)` answers the events of the session's log, or null when the
115
+ node keeps no log of it. `watch(sessionId, listener)` calls `listener` with each
116
+ new batch of events, and answers a function that stops the watch.
117
+
118
+ ### a2a
119
+
120
+ `a2a(body)` posts a JSON-RPC body to the node's A2A server and answers the
121
+ JSON answer. It serves `SendMessage`, `GetTask` and `CancelTask`. It does not
122
+ serve a streaming method.
123
+
124
+ ### deliver
125
+
126
+ ```ts
127
+ interface InboundDelivery { channel: string; bindingId: string; envelope: ChannelEnvelope }
128
+ interface DeliveryAnswer { accepted: boolean; duplicate: boolean; sessionId: string | null; reason: string | null }
129
+ ```
130
+
131
+ A channel service calls it for each message it receives. `duplicate: true`
132
+ means that the node had the message id already. `accepted: false` gives the
133
+ `reason`, such as "This host has no channels." The node checks the
134
+ `channel.receive` policy action first.
135
+
136
+ ### completeModel
137
+
138
+ ```ts
139
+ interface ModelRequest { model: string; system?: string; messages: Array<{ role: 'user' | 'assistant'; content: string }>; maxTokens?: number }
140
+ interface ModelCompletion { text: string; model: string; inputTokens: number; outputTokens: number }
141
+ ```
142
+
143
+ - `model` is `<provider>:<model>`, such as `anthropic:claude-haiku-4-5`.
144
+ - `maxTokens` is at most 8192. The default is 1024.
145
+ - The model must match a pattern in `permissions.models`, and the `model.use`
146
+ policy action decides each call. The key never reaches the plugin.
147
+ - Pass `signal` to stop a long call.
148
+
149
+ ### startMission
150
+
151
+ ```ts
152
+ interface MissionStart { team: string | null; mission: string; input: string | null; source: { event: string; key: string } }
153
+ interface MissionStarted { missionId: string; sessionId: string; taskId: string; duplicate: boolean }
154
+ ```
155
+
156
+ - `mission` is a mission id. The definition must hold `missions/<id>/MISSION.md`,
157
+ unless the team is not on this node: then the node starts the team as an
158
+ agent, and a directory or a placement can send it to another node.
159
+ - `source.event` names the event, such as `github.issue.opened`. `source.key`
160
+ is a stable key of that event, at most 200 characters. A second start with
161
+ the same key answers `duplicate: true` and starts nothing.
162
+ - The node checks the `mission.start` policy action first, with this plugin as
163
+ `plugin`.
164
+
165
+ ### missions
166
+
167
+ `missions()` answers the missions that this node holds, with their state,
168
+ lead, question, giver and history. It never includes the missions of other
169
+ nodes, so a relay can ask each node which missions it holds.
170
+
171
+ ### joinMission, replyMission, sayMission, pauseMission, resumeMission, askGiver
172
+
173
+ These are the far side of the `missions` port. A relay plugin on the node that
174
+ holds the mission calls them when the same plugin on another node sends it a
175
+ request. See [ports.md](ports.md#missions).
176
+
177
+ ```ts
178
+ interface RelayJoin { missionId: string; giver: { node: string; sessionId: string } }
179
+ interface RelayReply { missionId: string; text: string; by: string }
180
+ interface RelayControl { missionId: string; by: string }
181
+ interface RelayMessage { missionId: string; text: string; by: string }
182
+ interface Said { sessionId: string; missionId: string | null; delivery: 'reply' | 'steer' | 'turn' }
183
+ interface MissionQuestion { missionId: string; node: string; lead: string | null; leadSessionId: string; question: string; giver: { node: string; sessionId: string } }
184
+ ```
185
+
186
+ - `joinMission` makes the remote session the giver of the mission. When the
187
+ mission waits, the node sends the question to the new giver at once.
188
+ - `replyMission` answers a waiting mission. It fails when the mission is
189
+ paused, or is not waiting.
190
+ - `sayMission` gives the words to the lead of the mission. `delivery` says
191
+ how: `steer` (in its running turn), `turn` (as its next turn) or `reply` (as
192
+ the answer to its question). It fails when the mission is paused, or has no
193
+ lead.
194
+ - `pauseMission` pauses an active or waiting mission. `resumeMission` resumes a
195
+ paused one.
196
+ - `askGiver` gives the question to the giver session on this node. It answers
197
+ false when the giver is not an attached session of this node.
198
+ - Each of these acts only on this node. None relays again.
199
+
200
+ ### snapshot
201
+
202
+ `snapshot()` answers the graph of the node now:
203
+
204
+ ```ts
205
+ interface TelemetrySnapshot {
206
+ v: 'orbital.telemetry/v1'; node: string; sequence: number; at: string; startedAt: string;
207
+ teams: Array<{ name: string; node: string; purpose: string | null; lead: string | null; members: Array<{ agent: string; role: 'lead' | 'member'; responsibility: string | null; harness: string | null; placement: string | null; state: 'working' | 'idle'; lastActivityAt: string | null; work: MemberWork[] }> }>;
208
+ missions: Array<{ missionId: string; team: string | null; state: MissionState; sessionId: string | null; lead: string | null; giver: string | null; givenBy: string | null; question: string | null; result: string | null; updatedAt: string | null }>;
209
+ sessions: Array<{ sessionId: string; parentSessionId: string | null; agent: string; harness: string; team: string | null; mission: string | null; role: string; placement: string; running: boolean; since: string | null }>;
210
+ jobs: Array<{ jobId: string; sessionId: string; parentSessionId: string | null; agent: string; harness: string; team: string | null; mission: string | null; since: string }>;
211
+ tokens: Array<{ team: string | null; mission: string | null; provider: string; model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; costUsd: number; llmCalls: number; turns: number }>;
212
+ }
213
+ ```
214
+
215
+ `sequence` is the `seq` of the last event in the snapshot. Apply only the
216
+ telemetry events with a higher `seq`. The JSON Schema is in the `schemas` folder
217
+ of `@kaiba-cloud/orbital`.
218
+
219
+ ## Calls from the plugin to the node on stdio
220
+
221
+ Besides the HTTP host API, the SDK uses two stdio methods for you:
222
+
223
+ - `context.log(level, message)` sends `host.log`. The node writes it to its log.
224
+ `level` is `info`, `warn` or `error`.
225
+ - `context.secret(name)` sends `host.secret` and answers the value or null. The
226
+ node refuses a name that `permissions.secrets` does not declare.
@@ -0,0 +1,154 @@
1
+ # The plugin files: orbital-plugin.json, the manifest, permissions, secrets
2
+
3
+ A plugin has two descriptions:
4
+
5
+ - `orbital-plugin.json`: a file in the plugin's folder. It tells the node how
6
+ to start the plugin.
7
+ - The manifest: the answer of the plugin to `initialize`. It tells the node
8
+ what the plugin provides and what it reaches. With the SDK, `manifestOf`
9
+ builds it from your plugin object, so you never write it.
10
+
11
+ ## orbital-plugin.json
12
+
13
+ ```json
14
+ {
15
+ "name": "lan",
16
+ "command": ["node", "dist/plugin.js"],
17
+ "dir": ".",
18
+ "env": ["LAN_SHARED_KEY"],
19
+ "config": { "port": 7420 }
20
+ }
21
+ ```
22
+
23
+ | Field | Required | Meaning |
24
+ |---|---|---|
25
+ | `name` | yes | The plugin's name: lowercase letters, digits and hyphens, at most 63. |
26
+ | `command` | yes | The program and its arguments. `node` runs with the node's own Node. |
27
+ | `dir` | no | The working folder, relative to the plugin's folder. Default: the plugin's folder. |
28
+ | `env` | no | Environment variables the node passes to the plugin. It must list the same names as the secrets in the manifest that have the form of a variable (`[A-Z][A-Z0-9_]*`). |
29
+ | `config` | no | Default config. The `config` of the `config.yaml` entry overrides it key by key. |
30
+
31
+ No other field is allowed. A `config.yaml` source `exec:<command> <args>`
32
+ starts a program on PATH with no `orbital-plugin.json`. Only such an entry can
33
+ name `env:` in `config.yaml`.
34
+
35
+ The node starts the plugin with a small environment: `PATH`, `HOME`, the
36
+ locale, the temp folders, the proxy and certificate variables, and the
37
+ variables in `env`. Any other variable of the node does not reach the plugin.
38
+
39
+ ## The config.yaml entry
40
+
41
+ ```yaml
42
+ plugins:
43
+ lan:
44
+ source: ./plugins/lan # a folder, npm:<package>, git:<url>, <name>@<marketplace>, or exec:<command>
45
+ enabled: true # false keeps the entry and does not start the plugin
46
+ config: # context.init.config, merged over the config of orbital-plugin.json
47
+ port: 7420
48
+ ```
49
+
50
+ `orbital plugin add <source>` writes the entry. A folder source stays where it
51
+ is. Any other source is installed in `<orbital home>/plugins/<name>`.
52
+
53
+ ## The manifest
54
+
55
+ ```ts
56
+ interface PluginManifest {
57
+ name: string;
58
+ version: string;
59
+ ports: 'orbital/v1alpha1';
60
+ provides: {
61
+ directory: boolean;
62
+ placements: Array<{ kind: string; decides: boolean }>;
63
+ policy: { deterministic: boolean } | null;
64
+ telemetry: { usage: boolean; span: boolean; event: boolean } | null;
65
+ sessions: Array<'create' | 'open' | 'close' | 'deliver'>;
66
+ tools: string[];
67
+ channels: Array<{ id: string; capabilities: ChannelCapabilities }>;
68
+ store: { kinds: StateKind[]; lease: boolean } | null;
69
+ workspace: boolean;
70
+ harnesses: Array<{ id: string; description: string; callsModel: boolean }>;
71
+ service: { done: boolean } | null;
72
+ actions: boolean;
73
+ workflows: { engines: string[] } | null;
74
+ reasoner: boolean;
75
+ missions: boolean;
76
+ };
77
+ permissions: { network: string[]; secrets: string[]; models: string[] };
78
+ commands: Array<{ name: string; summary: string }>;
79
+ panels: Array<{ id: string; title: string; kind: 'list' | 'tree' | 'transcript' | 'form' }>;
80
+ }
81
+ ```
82
+
83
+ - `name` must be the key of the plugin in `config.yaml`. Else the node refuses
84
+ it: "is X, and config.yaml names it Y".
85
+ - `ports` must be the version of the node. Build with the SDK of the node's
86
+ version.
87
+ - The node makes the remote ports only from `provides`. A method that you give
88
+ and the manifest does not name is never called.
89
+ - A plugin cannot have the name of a definition folder: `agents`,
90
+ `blueprints`, `local`, `policies`, `skills`, `system`, `teams`, `vendor`,
91
+ `workflows`.
92
+
93
+ ## Permissions
94
+
95
+ ```ts
96
+ permissions: {
97
+ network: ['api.example.com', '*.example.net'],
98
+ secrets: ['example.token', 'EXAMPLE_KEY'],
99
+ models: ['anthropic:*'],
100
+ },
101
+ ```
102
+
103
+ | Field | Meaning | What the node does |
104
+ |---|---|---|
105
+ | `network` | Every host the plugin reaches, such as `*.example.net`. | `offline: true` in `config.yaml` refuses a plugin with any host. The node does not sandbox the plugin process: the list is a promise that a person and a policy read. |
106
+ | `secrets` | Every secret the plugin reads. | `context.secret(name)` fails for a name not listed. A name in the form of a variable also comes in the environment. |
107
+ | `models` | Every model the plugin calls through `host.completeModel`, as `<provider>:<model>`. `*` matches any part. | A call to a model that no pattern matches fails. The `model.use` policy decides each call. |
108
+
109
+ - `orbital plugin add` prints the permissions. A person reads them before they
110
+ use the plugin.
111
+ - When a plugin starts, the node checks the `plugin.load` policy action with
112
+ `network`, `secrets` and `models`. In this version the node checks it before
113
+ any policy engine runs, so no policy plugin refuses a plugin yet.
114
+ - Declare a LAN plugin's peers as hosts too, such as `192.168.1.*` or
115
+ `*.local`. Then a person sees that it talks on the network.
116
+
117
+ ## Secrets
118
+
119
+ A person keeps a secret on the machine:
120
+
121
+ ```bash
122
+ orbital plugin secret set <plugin> <name> # reads the value from stdin
123
+ orbital plugin secret remove <plugin> <name>
124
+ ```
125
+
126
+ The plugin reads it when it needs it, not at start:
127
+
128
+ ```ts
129
+ const token = await context.secret('example.token');
130
+ if (!token) throw new Error('No token: run `orbital plugin secret set example example.token`.');
131
+ ```
132
+
133
+ The node keeps secrets in the system's secret store, or in a file of the
134
+ orbital home. A secret never goes in `config.yaml` or in `orbital-plugin.json`.
135
+
136
+ ## The plugin's folder and the init data
137
+
138
+ `context.init` is what the node gives in `initialize`:
139
+
140
+ ```ts
141
+ interface PluginInit {
142
+ config: Record<string, unknown>;
143
+ folder: string | null;
144
+ node: { name: string; workspaceDir: string; harness: string; harnesses: string[]; once: boolean; idleExitMs: number | null; project: string | null };
145
+ }
146
+ ```
147
+
148
+ - `config`: the merged config. Validate it at the start, and throw an error that
149
+ names the bad key. The node shows it.
150
+ - `folder`: `<definition folder>/<plugin name>`, such as `.atom/lan/`. Put the
151
+ files that a person writes for the plugin there. The folder can be absent, so
152
+ give a default. It is null when the node has no definition folder.
153
+ - `node.name`: the name of this node. A cross-machine plugin uses it to
154
+ introduce itself to peers.