@orchestraworks/cli 0.3.0 → 0.5.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.
- package/README.md +23 -1256
- package/dist/cli/core/banner.d.ts +10 -0
- package/dist/cli/core/banner.js +63 -0
- package/dist/cli/core/invocation.js +1 -1
- package/dist/cli/core/package-root.d.ts +3 -2
- package/dist/cli/core/package-root.js +4 -4
- package/dist/cli/core/skill-pointer.d.ts +7 -2
- package/dist/cli/core/skill-pointer.js +13 -3
- package/dist/cli/factory/init.d.ts +1 -1
- package/dist/cli/factory/init.js +41 -13
- package/dist/cli/main.js +5 -3
- package/dist/cli/skill-install-fs.d.ts +0 -2
- package/dist/cli/skill-install-fs.js +0 -2
- package/dist/cli/skill.js +20 -7
- package/docs/REFERENCE.md +1278 -0
- package/node_modules/@orchestraworks/client/package.json +1 -1
- package/package.json +7 -8
- package/skills/orchestra/SKILL.md +151 -110
- package/skills/orchestra-fdk/SKILL.md +327 -120
- package/skills/orchestra-fdk/references/authoring.md +26 -15
- package/skills/orchestra-fdk/references/environment.md +39 -24
- package/templates/bug-fix/tsconfig.json +12 -0
- package/templates/engineering-team/lib/setup.sh +16 -0
- package/templates/engineering-team/package.json +1 -1
- package/templates/todo-factory/package.json +1 -1
- package/node_modules/@orchestraworks/workflow-render/LICENSE +0 -202
- package/node_modules/@orchestraworks/workflow-render/dist/constants.d.ts +0 -98
- package/node_modules/@orchestraworks/workflow-render/dist/context.d.ts +0 -37
- package/node_modules/@orchestraworks/workflow-render/dist/geometry-core.d.ts +0 -40
- package/node_modules/@orchestraworks/workflow-render/dist/graph-digest.d.ts +0 -4
- package/node_modules/@orchestraworks/workflow-render/dist/grid.d.ts +0 -33
- package/node_modules/@orchestraworks/workflow-render/dist/heuristic.d.ts +0 -7
- package/node_modules/@orchestraworks/workflow-render/dist/identity.d.ts +0 -9
- package/node_modules/@orchestraworks/workflow-render/dist/index.d.ts +0 -24
- package/node_modules/@orchestraworks/workflow-render/dist/index.js +0 -1
- package/node_modules/@orchestraworks/workflow-render/dist/layout.d.ts +0 -6
- package/node_modules/@orchestraworks/workflow-render/dist/normalize.d.ts +0 -33
- package/node_modules/@orchestraworks/workflow-render/dist/place.d.ts +0 -81
- package/node_modules/@orchestraworks/workflow-render/dist/ports.d.ts +0 -15
- package/node_modules/@orchestraworks/workflow-render/dist/presence.d.ts +0 -6
- package/node_modules/@orchestraworks/workflow-render/dist/route-cells.d.ts +0 -15
- package/node_modules/@orchestraworks/workflow-render/dist/sha256.d.ts +0 -3
- package/node_modules/@orchestraworks/workflow-render/dist/svg.d.ts +0 -7
- package/node_modules/@orchestraworks/workflow-render/dist/text.d.ts +0 -14
- package/node_modules/@orchestraworks/workflow-render/dist/types.d.ts +0 -310
- package/node_modules/@orchestraworks/workflow-render/dist/verify.d.ts +0 -26
- package/node_modules/@orchestraworks/workflow-render/package.json +0 -42
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
# orchestra: the full reference
|
|
2
|
+
|
|
3
|
+
The full developer guide for `@orchestraworks/cli`: the agent-as-code kit and its `orchestra` CLI.
|
|
4
|
+
The short introduction is the package [README](../README.md).
|
|
5
|
+
|
|
6
|
+
> This guide assumes you already have a **running** orchestra platform to target (SaaS or self-hosted):
|
|
7
|
+
> `ORCHESTRA_BASE_URL` points at it, and you hold an API token. It does **not** cover standing up a
|
|
8
|
+
> local stack.
|
|
9
|
+
|
|
10
|
+
## 1. What is orchestra
|
|
11
|
+
|
|
12
|
+
orchestra is CDK for your software factory: you declare agents, environments, secrets, connections, and
|
|
13
|
+
workflows as typed TypeScript constructs and apply them to build one. `synth` compiles them into a
|
|
14
|
+
deterministic, value-free plan; `apply` reconciles that plan against the platform's **public APIs**
|
|
15
|
+
(last-writer-wins on the fields you manage — orchestra makes no platform changes). Like CDK, construct
|
|
16
|
+
paths derive stable logical ids, and the plan is a reviewable artifact you can commit and diff. That
|
|
17
|
+
model in condensed form — the construct tree, the loop, and the six design principles that settle
|
|
18
|
+
design questions — ships with the kit in
|
|
19
|
+
[`skills/orchestra-fdk/references/authoring.md`](../skills/orchestra-fdk/references/authoring.md), beside the
|
|
20
|
+
two skills [`skills/orchestra/SKILL.md`](../skills/orchestra/SKILL.md) and
|
|
21
|
+
[`skills/orchestra-fdk/SKILL.md`](../skills/orchestra-fdk/SKILL.md); §2 links both into your own coding harness.
|
|
22
|
+
|
|
23
|
+
## 2. Prerequisites & install
|
|
24
|
+
|
|
25
|
+
- **Node ≥ 22** (see `engines` in `package.json`).
|
|
26
|
+
- **A `package.json` in the folder you install into.** In a new folder, run `npm init -y` before installing
|
|
27
|
+
the kit. Without one, npm installs into the nearest parent folder that has one.
|
|
28
|
+
|
|
29
|
+
The kit is published as `@orchestraworks/cli`. Install it globally, which puts `orchestra` on your path:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g @orchestraworks/cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run every command from the **factory directory** (the one holding `fdk.json`).
|
|
36
|
+
|
|
37
|
+
### Teach your coding agent: `orchestra skills install`
|
|
38
|
+
|
|
39
|
+
The kit ships two skills. `skills/orchestra/SKILL.md` is the general reference: the platform's ideas, how
|
|
40
|
+
the CLI works, what the platform cannot do yet, and where an agent's knowledge belongs.
|
|
41
|
+
`skills/orchestra-fdk/SKILL.md` builds and applies a factory for an existing codebase, with the authoring
|
|
42
|
+
reference and environment porting in its `references/` folder. Hand them to your coding agent once:
|
|
43
|
+
|
|
44
|
+
1. Install the CLI globally: `npm install -g @orchestraworks/cli`.
|
|
45
|
+
2. Link both skills into your harness, naming the harness and the scope:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
orchestra skills install --harness claude --scope user
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--harness` is `claude`, `codex` or `both`. Both flags are required, because a coding agent has no
|
|
52
|
+
terminal to answer a question.
|
|
53
|
+
3. Read the `SKILL.md` in each skill folder the command prints. A harness loads a new skill only at its next
|
|
54
|
+
start, so read them now.
|
|
55
|
+
4. `npm update -g @orchestraworks/cli` keeps the CLI and both skills current.
|
|
56
|
+
|
|
57
|
+
The command **links** each skill under its own name — `orchestra` and `orchestra-fdk` — into the harness's
|
|
58
|
+
skills folder (Claude Code → `.claude/skills/`; Codex → `.agents/skills/`), from the package that runs it.
|
|
59
|
+
It never copies: a copy silently rots, while a link keeps teaching whatever the last update installed. Every
|
|
60
|
+
destination is checked before anything is created, so one blocked path means no link is made; an occupied
|
|
61
|
+
path is refused with the remedy rather than overwritten, and a dead link left by a package relocation is the
|
|
62
|
+
one thing it repairs. `--scope project` links into this project and is accepted only when the kit is
|
|
63
|
+
installed in this project. `--harness other` prints both skills' paths instead of linking. A run from npm's
|
|
64
|
+
`npx` cache is refused, because npm may clean that cache at any time. Nothing here touches the platform.
|
|
65
|
+
|
|
66
|
+
## 3. Authenticate
|
|
67
|
+
|
|
68
|
+
The CLI is **identity-agnostic**: it sends the methods, and the platform's IAM decides what the caller
|
|
69
|
+
may do. It never inspects what kind of bearer it holds. `login` is the one exception, and only at
|
|
70
|
+
**persist** time — it refuses to save an **agent runtime token** (recognizable by its `sfagt_v1~` prefix)
|
|
71
|
+
as a workstation profile, because a profile is meant to be a durable operator identity.
|
|
72
|
+
|
|
73
|
+
**Interactive (`login`)** — for a workstation:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
orchestra auth login # browser sign-in (the default)
|
|
77
|
+
orchestra auth login --no-browser # paste an existing API token instead
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
By default `login` **opens your browser**: it prints a sign-in URL and opens it, catches the redirect on
|
|
81
|
+
a loopback port it holds for the duration, exchanges the result for a token pair, confirms which
|
|
82
|
+
organization the profile is for, and saves a `0600` profile to `~/.config/orchestra/config.json`. Because
|
|
83
|
+
that profile carries a **renewal credential**, ordinary commands keep working after the short-lived
|
|
84
|
+
access token expires — the CLI refreshes silently, both before a command runs and on a 401 mid-flight.
|
|
85
|
+
|
|
86
|
+
The browser has to run on **the same machine as the CLI**: the sign-in returns to a local address only
|
|
87
|
+
that machine can reach. Over SSH or from a container, forward the printed port to your own machine, or
|
|
88
|
+
use the paste flow.
|
|
89
|
+
|
|
90
|
+
**`login` never asks which platform to sign in to.** It resolves the address from four tiers, in order —
|
|
91
|
+
`--base-url`, then `ORCHESTRA_BASE_URL` (or `PLATFORM_BASE_URL`), then the base URL already stored in the profile you are logging
|
|
92
|
+
into (so re-running `login` stays on that profile's platform), and otherwise the built-in default
|
|
93
|
+
`https://api.orchestraworks.ai` — and prints the address in effect, with where it came from, as its first line:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
Platform: https://api.orchestraworks.ai (default — override with --base-url or ORCHESTRA_BASE_URL)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Another deployment — a staging platform, a local dev stack — is reached by **overriding**, never by being
|
|
100
|
+
asked: `ORCHESTRA_BASE_URL=https://…` or `--base-url https://…`. If the address in effect answers nothing
|
|
101
|
+
(no DNS record, nothing listening), `login` says exactly that, names the address and where it came from,
|
|
102
|
+
and points at the two overrides — it does not report a bare network error.
|
|
103
|
+
|
|
104
|
+
**With `ORCHESTRA_API_TOKEN` set, only the two explicit tiers may name the platform.** That token already belongs
|
|
105
|
+
to one deployment and `login` cannot tell which, so it will not infer an address for it from your stored
|
|
106
|
+
profile or from the default — sending an exported token to the wrong platform would disclose it there.
|
|
107
|
+
Name the deployment with `--base-url` or `ORCHESTRA_BASE_URL`, or the login is refused before anything is
|
|
108
|
+
sent. A token you *paste* at the hidden prompt keeps all four tiers: you are at the terminal, reading the
|
|
109
|
+
address on screen before you type.
|
|
110
|
+
|
|
111
|
+
`--no-browser` is the paste flow: a hidden prompt for an existing API token (nothing you type is
|
|
112
|
+
echoed), validated against `GET /organizations` (confirming the org is one of your memberships) and
|
|
113
|
+
stored the same way. A pasted token cannot renew itself. With `ORCHESTRA_API_TOKEN` set, a bare `orchestra auth login`
|
|
114
|
+
stores that token and never opens a browser.
|
|
115
|
+
|
|
116
|
+
`--profile <name>` stores/reads named profiles. `--org <id>` (or `ORCHESTRA_ORGANIZATION_ID`) names the
|
|
117
|
+
organization the profile is for, and **the two flows ask for it differently**: after a browser sign-in a
|
|
118
|
+
lone membership is selected for you and several prompt you to choose — with no terminal to ask at, that
|
|
119
|
+
login refuses and tells you to pass `--org`. The paste flow neither selects nor checks for a terminal: it
|
|
120
|
+
asks for an organization id every time one was not supplied, however many you belong to, so a
|
|
121
|
+
non-interactive `--no-browser` (or `ORCHESTRA_API_TOKEN`) login must pass `--org`/`ORCHESTRA_ORGANIZATION_ID` itself.
|
|
122
|
+
The organization's slug is accepted wherever its id is.
|
|
123
|
+
There is **no `--token` flag** in any spelling — a token on the command line is visible in `ps`, in shell
|
|
124
|
+
history and in CI logs.
|
|
125
|
+
|
|
126
|
+
**CI / env** — no `login` needed:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
export ORCHESTRA_BASE_URL="https://factory.example.com"
|
|
130
|
+
export ORCHESTRA_API_TOKEN="…" # any principal's bearer; the CLI never inspects it
|
|
131
|
+
export ORCHESTRA_ORGANIZATION_ID="org_…"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**The auth-provider chain** (one ordered chain for every command, presence is selection):
|
|
135
|
+
`ORCHESTRA_API_TOKEN` → the `login` profile → the platform-injected machine edge flag
|
|
136
|
+
(`SFDK_AUTH_EDGE_INJECTED`, which makes the CLI send the request **bare** so the egress edge
|
|
137
|
+
stamps the agent's authorization — no credential ever enters the machine) → a named
|
|
138
|
+
missing-credentials error. There is **no `--token` flag**: a token never appears in argv.
|
|
139
|
+
|
|
140
|
+
**Resolved alongside the chain, per field:** the base URL (`--base-url` → `ORCHESTRA_BASE_URL`, then `PLATFORM_BASE_URL` → profile;
|
|
141
|
+
`login` alone adds the built-in default as a fourth tier, because it is the command that has no profile
|
|
142
|
+
to read yet — every other command needs one of the three, and says so if it has none)
|
|
143
|
+
and the organization (`--org` → `ORCHESTRA_ORGANIZATION_ID` → profile; under the edge provider the organization
|
|
144
|
+
rides the stamped identity server-side). Every authenticated request requires HTTPS or loopback — the
|
|
145
|
+
bearer is itself a credential — with the unauthenticated `health` ping the one exemption. `diff`/`apply`
|
|
146
|
+
print the resolved target org as a banner before any read/write, and warn when an exported
|
|
147
|
+
`ORCHESTRA_ORGANIZATION_ID` diverges from the profile's org (so a stale export can't silently redirect writes).
|
|
148
|
+
|
|
149
|
+
The conversation (`SFDK_SESSION_ID`, under every provider). The worker sets it on each harness process
|
|
150
|
+
it starts, so a command an agent runs knows which conversation it is in. When it is set, `orchestra` adds
|
|
151
|
+
`sessionId` to every JSON request body that has none: typed commands, `orchestra api`, `apply`, and the
|
|
152
|
+
`login` and refresh token requests. A body that already names a `sessionId` is sent unchanged, so
|
|
153
|
+
`artifacts create --session-id <id>` wins. Query strings, uploads and other non-JSON bodies are never
|
|
154
|
+
changed, and an empty or whitespace-only value counts as unset. The platform checks a supplied session as
|
|
155
|
+
usual. An artifact takes its project from the session, so a command that must publish into another project
|
|
156
|
+
than its conversation's runs without the variable: `env -u SFDK_SESSION_ID orchestra artifacts create …`. A
|
|
157
|
+
value you export by hand in your own shell applies the same way.
|
|
158
|
+
|
|
159
|
+
## 4. Author your first factory
|
|
160
|
+
|
|
161
|
+
### Start from the bug-fix starter: `orchestra fdk init`
|
|
162
|
+
|
|
163
|
+
In a new folder beside your codebase:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm init -y
|
|
167
|
+
npm install @orchestraworks/cli
|
|
168
|
+
npx orchestra fdk init --repository acme/shop
|
|
169
|
+
npm install
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`init` writes a bug-fix factory for that GitHub repository: `fdk.json`, `main.ts`, the machine image
|
|
173
|
+
(`image/Dockerfile` and a setup script, `image/setup.sh`, that starts Docker on each machine), the agent's
|
|
174
|
+
standing instructions (`agents/bugfix.md`) and an empty codebase skill (`skills/codebase/SKILL.md`). It sets
|
|
175
|
+
`"type": "module"` in `package.json` and adds `tsx` to its development dependencies, keeping every other key.
|
|
176
|
+
`--model-key anthropic` or `--model-key openai` adds your organization's own model key as a
|
|
177
|
+
`ModelProviderKey`, and `openai` also selects the `codex` harness; without it the agent runs on credits
|
|
178
|
+
through the model gateway.
|
|
179
|
+
|
|
180
|
+
It touches no platform and overwrites nothing: when a file path it would write already exists, a folder path it
|
|
181
|
+
would write into is not a folder, or a link sits at either or at `package.json`, the folder has no
|
|
182
|
+
`package.json`, or `package.json` does not list the kit or lists a package other than the kit and `tsx`, it
|
|
183
|
+
names each one and writes nothing. It then prints the next steps: `npm install`, the shell inputs `main.ts`
|
|
184
|
+
reads and signing Docker in to the image registry, `npx orchestra fdk diff`, and `npx orchestra fdk apply`, which prints the
|
|
185
|
+
GitHub App install link and waits for you to install it. The `orchestra-fdk` skill (§2) takes a coding agent through
|
|
186
|
+
fitting a starter to your codebase.
|
|
187
|
+
|
|
188
|
+
### Start from another starter: `orchestra fdk init --template <name>`
|
|
189
|
+
|
|
190
|
+
The kit carries more than one starter. `engineering-team` is a factory for one engineering team: a Manager
|
|
191
|
+
Agent and Engineer Agents sm, md and lg, their machines, and four pipelines. A starter other than `bug-fix`
|
|
192
|
+
brings its own `package.json`, so run it in a new, empty folder, with nothing installed:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
mkdir factory && cd factory
|
|
196
|
+
npx --package @orchestraworks/cli orchestra fdk init --template engineering-team
|
|
197
|
+
npm install
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
It copies the starter's files as they are and fills nothing in, so `--repository` and `--model-key` are
|
|
201
|
+
refused with it. It refuses an unknown name and lists the starters the kit carries. It overwrites nothing:
|
|
202
|
+
when a file path it would write exists, a folder path it would write into is not a folder, or a link sits at
|
|
203
|
+
either, `package.json` included, it names each one and writes nothing. Then read the
|
|
204
|
+
`README.md` it wrote, which names the values to fill in and the steps to run.
|
|
205
|
+
|
|
206
|
+
### The factory program
|
|
207
|
+
|
|
208
|
+
A factory program is an ordinary Node program. Two files:
|
|
209
|
+
|
|
210
|
+
`fdk.json` — names the command that runs the factory program:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{ "factory": "npx tsx main.ts" }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`main.ts` — the declarative definition (imports are exactly what `orchestra` exports):
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import {
|
|
220
|
+
Agent,
|
|
221
|
+
EgressPolicy,
|
|
222
|
+
Environment,
|
|
223
|
+
Factory,
|
|
224
|
+
Image,
|
|
225
|
+
Secret,
|
|
226
|
+
SecretValue,
|
|
227
|
+
bearer,
|
|
228
|
+
} from "@orchestraworks/cli";
|
|
229
|
+
|
|
230
|
+
const factory = new Factory({ name: "acme-support" });
|
|
231
|
+
|
|
232
|
+
const openaiKey = new Secret(factory, "openai-api-key", {
|
|
233
|
+
name: "OpenAI API key",
|
|
234
|
+
value: SecretValue.fromEnv("OPENAI_API_KEY_VALUE"), // value read from env at apply — never in the plan
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const runtime = new Environment(factory, "runtime", {
|
|
238
|
+
name: "Support agent environment",
|
|
239
|
+
image: Image.fromTag("acme/support-agent:1.0.0"),
|
|
240
|
+
platform: { os: "linux", arch: "amd64" },
|
|
241
|
+
resources: { cpu: "2", memory: "4G" }, // an Agent Compute Capacity: cores : 2 G per core
|
|
242
|
+
env: { LOG_LEVEL: "debug" }, // plaintext configuration for the agent process — NOT a secret store:
|
|
243
|
+
// these values are stored unencrypted and returned by every read, so a
|
|
244
|
+
// credential belongs in a Secret bound to an egress allow rule instead.
|
|
245
|
+
egress: EgressPolicy.denyAll().allow("openai-model-api", {
|
|
246
|
+
hosts: ["api.openai.com"],
|
|
247
|
+
priority: 300,
|
|
248
|
+
credential: bearer("Authorization", openaiKey), // binds the Secret to an injected header
|
|
249
|
+
}),
|
|
250
|
+
ingress: [{ name: "web", port: 3000 }],
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
new Agent(factory, "agent", {
|
|
254
|
+
name: "Support Agent",
|
|
255
|
+
environment: runtime,
|
|
256
|
+
harness: "claude", // required: which harness family answers this agent's turns
|
|
257
|
+
instructions: "You are a support agent. Keep raw credentials out of logs and outputs.",
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- **ESM factory program.** The program runs as an ES module, so its own `package.json` must declare `"type": "module"`
|
|
262
|
+
(the in-repo example carries it). Without it, a fresh out-of-repo consumer — `npm init -y` defaults to
|
|
263
|
+
CommonJS — fails at the very first `import` with `ERR_PACKAGE_PATH_NOT_EXPORTED`, because the package
|
|
264
|
+
`exports` map exposes only an `import` condition for `"."`. (Two files are orchestra-specific; the program still
|
|
265
|
+
needs an ordinary ESM `package.json` alongside them.)
|
|
266
|
+
- **Construct id → slug.** With no explicit `slug`, the slug is the **factory name + full construct path**,
|
|
267
|
+
kebab-joined: the secret above becomes `acme-support-openai-api-key`, the environment
|
|
268
|
+
`acme-support-runtime`, the agent `acme-support-agent`. Every derived slug must be ≤ 60 chars and match
|
|
269
|
+
`^[a-z0-9]+(-[a-z0-9]+)*$`; synth fails loudly (no silent hashing) if it doesn't.
|
|
270
|
+
- An explicit **`slug`** prop is verbatim — no namespace, no transformation.
|
|
271
|
+
- Renaming the factory or any path segment means **"new resource"** (the old slug becomes an orphan).
|
|
272
|
+
- Passing a construct object where a reference is expected *is* passing the reference; there is no `.ref`.
|
|
273
|
+
|
|
274
|
+
### The durable home: state that survives a machine recycle
|
|
275
|
+
|
|
276
|
+
An environment's **durable home** is one volume mounted at `/home`. It is what makes a conversation
|
|
277
|
+
continue: the agent's own state directory lives under `$HOME`, so when a machine is reclaimed and a later turn
|
|
278
|
+
revives it, the agent replays its history instead of starting over. `/home` is the path because that is where
|
|
279
|
+
the account inside the runtime image has its home directory — the platform sets no `HOME` of its own, so
|
|
280
|
+
mounting a volume at the account's home is what makes that home durable.
|
|
281
|
+
|
|
282
|
+
There is no dedicated prop for it. `/home` is an **ordinary volume mount** — the same `volumeMounts` array
|
|
283
|
+
as any other path, and the same reference rules:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
new Environment(factory, "runtime", {
|
|
287
|
+
image: "acme/support-agent:1.0.0",
|
|
288
|
+
egress: EgressPolicy.denyAll(),
|
|
289
|
+
volumeMounts: [
|
|
290
|
+
{ volume: Volume.fromSlug(factory, "support-home"), mountPath: "/home" },
|
|
291
|
+
{ volume: Volume.fromSlug(factory, "cache-vol"), mountPath: "/var/cache" },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Declare no `/home` mount and the environment keeps no durable home** — every machine it starts begins with a
|
|
297
|
+
fresh home directory, which is the right choice for a stateless environment. Nothing is derived on your
|
|
298
|
+
behalf either way: the mount list you write is the mount list that applies.
|
|
299
|
+
|
|
300
|
+
The platform sets neither `HOME` nor `USERPROFILE`. You may declare `HOME` in the environment's `env` map to
|
|
301
|
+
point the harness at a different directory, and it will conform to that or complain — but a home off the
|
|
302
|
+
mounted volume is a home that does not survive a machine recycle.
|
|
303
|
+
|
|
304
|
+
A `Volume` is a platform resource orchestra only **references**, exactly like an image tag — orchestra never creates
|
|
305
|
+
one. Create it first, then mount it:
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
orchestra volumes create --name "Support agent home" --slug support-home --size 50G
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Then reference that slug with `Volume.fromSlug(factory, "support-home")`. Because a mount is plain desired
|
|
312
|
+
state, editing the list converges the ordinary way: add an entry and the next apply mounts it, remove one
|
|
313
|
+
and the next apply unmounts it. Removing a `/home` mount **detaches** the volume — it is kept, stays listed
|
|
314
|
+
under `/volumes`, and never loses its data.
|
|
315
|
+
|
|
316
|
+
### Declared images: `Image.fromTag` vs `Image.fromBuild`
|
|
317
|
+
|
|
318
|
+
`Image.fromTag("acme/support-agent:1.0.0")` is a pure reference — the tag rides the environment payload
|
|
319
|
+
and you build/push the image yourself. `Image.fromBuild` instead declares an image the factory builds
|
|
320
|
+
and `apply` publishes for you (single build step — the Dockerfile pins its base in `FROM`):
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const factory = new Factory({ name: "acme-support" }); // MUST precede any fromBuild call
|
|
324
|
+
|
|
325
|
+
const agentImage = Image.fromBuild({
|
|
326
|
+
dockerfile: "../Dockerfile", // required, relative to the factory dir
|
|
327
|
+
context: "../../../..", // optional, relative to factory dir; default "."
|
|
328
|
+
tag: process.env.ORCHESTRA_FDK_AGENT_IMAGE ?? "ghcr.io/acme/support-agent:1.4",
|
|
329
|
+
// platform: "linux/amd64", // optional: docker build --platform target
|
|
330
|
+
// buildArgs: { SOME_VERSION: "9.5.1" }, // optional, rare: genuine build parameters only
|
|
331
|
+
});
|
|
332
|
+
// … image: agentImage,
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
| Prop | Required | Meaning |
|
|
336
|
+
| --- | --- | --- |
|
|
337
|
+
| `dockerfile` | yes | Dockerfile path, relative to the factory dir (may escape it, e.g. `../Dockerfile`). |
|
|
338
|
+
| `context` | no | Build context, relative to the factory dir; defaults to `"."`. |
|
|
339
|
+
| `tag` | yes | Image tag (≤ 512 chars); flows to the environment payload byte-identically to `fromTag`. |
|
|
340
|
+
| `platform` | no | `docker build --platform` target (e.g. `"linux/amd64"`). Absent leaves the invocation unchanged; set it to push a cross-architecture manifest — e.g. an arm64 workstation building the amd64 image the target machines pull. |
|
|
341
|
+
| `buildArgs` | no | `--build-arg` values. **Non-secret only** — recorded verbatim in `plan.json` and committed snapshots, so a secret here lands in a committed artifact; a high-entropy value triggers a `warnings.json` warning naming the key. Use a BuildKit `--secret` mount outside orchestra for secret build inputs. |
|
|
342
|
+
|
|
343
|
+
`new Factory(...)` must run **before** any `fromBuild` (the options-only call resolves the active
|
|
344
|
+
factory via a process singleton). Two `fromBuild` calls with the same tag are a synth error.
|
|
345
|
+
|
|
346
|
+
### Two starting points: `EgressPolicy.denyAll()` and `EgressPolicy.allowAll()`
|
|
347
|
+
|
|
348
|
+
`EgressPolicy.denyAll()` refuses every host no rule allows; `EgressPolicy.allowAll()` allows every host no
|
|
349
|
+
rule refuses. Every rule form folds onto either one the same way: a credentialed allow rule still injects its
|
|
350
|
+
credential at the edge, and a `deny` still refuses. A first factory starts open, since a first run fails far
|
|
351
|
+
more often on a blocked host than on anything else, and tightens once runs succeed: read which hosts the agent
|
|
352
|
+
reached, switch to `denyAll()`, and allow those.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
egress: EgressPolicy.allowAll()
|
|
356
|
+
.applyPreset(github.egressPolicy()) // the connection's credentialed rules
|
|
357
|
+
.deny("no-hosted-data", { hosts: ["*.supabase.co", "db.acme.example"] }), // keep the team's real data out
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Open egress opens hosts, not ports: a service on another port still needs a rule naming `host:port`.
|
|
361
|
+
|
|
362
|
+
### Egress allow rules: paths and multiple credential bindings
|
|
363
|
+
|
|
364
|
+
`EgressPolicy.denyAll().allow(name, rule)` opens one named outbound rule. Beyond `hosts` and
|
|
365
|
+
`priority` it takes:
|
|
366
|
+
|
|
367
|
+
- **`paths?: string[]`** — path globs the rule matches; default `["*"]` (all paths). Scope a rule to a
|
|
368
|
+
subtree with e.g. `paths: ["/backend-api/*"]`.
|
|
369
|
+
- **`credential?: ICredentialBinding`** — a single credential: a bare `Secret` (its own `injection`
|
|
370
|
+
recipe supplies the headers), or a `bearer`/`basic`/`header`/`inject` binding (see §6).
|
|
371
|
+
- **`credentials?: ICredentialBinding[]`** — several credentials injected on one request, for a
|
|
372
|
+
destination that needs more than one credential header. Both must ride **one** rule: the platform egress
|
|
373
|
+
matcher selects exactly one rule per (destination, path, method) and hydrates once per request, so
|
|
374
|
+
co-required headers cannot be split across two rules.
|
|
375
|
+
|
|
376
|
+
Every entry is an `ICredentialBinding` — a `Secret`/import or a factory result, never a plain object
|
|
377
|
+
literal. Valid credential shapes are **neither** (a credential-free allow — e.g. `allow(connection)`),
|
|
378
|
+
**one `credential`**, or a **non-empty `credentials` list**. Setting both forms, passing an empty
|
|
379
|
+
`credentials` array, or listing case-insensitively duplicate header names is a **synth error** (a
|
|
380
|
+
duplicate would silently drop a binding at broker hydration). A recipe binding (a bare `Secret` or an
|
|
381
|
+
`inject()` override) names no header of its own — its `injection` recipe does — so it never participates
|
|
382
|
+
in that uniqueness check; uniqueness still holds across the legacy `bearer`/`basic`/`header` bindings
|
|
383
|
+
beside it.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { bearer, header } from "@orchestraworks/cli";
|
|
387
|
+
|
|
388
|
+
EgressPolicy.denyAll().allow("chatgpt", {
|
|
389
|
+
hosts: ["chatgpt.com"],
|
|
390
|
+
paths: ["/backend-api/*"],
|
|
391
|
+
priority: 10,
|
|
392
|
+
credentials: [
|
|
393
|
+
bearer("Authorization", codexAccess),
|
|
394
|
+
header("ChatGPT-Account-Id", codexAccount), // raw value, no scheme prefix
|
|
395
|
+
],
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Ready-made egress: presets and a connection's own rules
|
|
400
|
+
|
|
401
|
+
Most of an environment's egress is reachability an app used to type out host by host. Two calls replace
|
|
402
|
+
that, and the line between them is the one rule to remember: **anonymous rules ride presets, credentialed
|
|
403
|
+
rules ride the connection.**
|
|
404
|
+
|
|
405
|
+
- **`egressPreset.softwareDevelopment()`** — a static bundle of **credential-free** public-internet allows
|
|
406
|
+
that anyone may apply. A preset is about *reachability*, never credentials.
|
|
407
|
+
- **`connection.egressPolicy(options?)`** — one connection's own outbound surface, compiled from the
|
|
408
|
+
**provider catalog**: the provider-API rules `allow(connection)` emits, plus the provider's git transport,
|
|
409
|
+
each bound to that connection's credential.
|
|
410
|
+
|
|
411
|
+
Both return the same bundle value, and `EgressPolicy.applyPreset(bundle)` folds either onto a policy —
|
|
412
|
+
chainable like `allow` and `deny`:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import { EgressPolicy, egressPreset } from "@orchestraworks/cli";
|
|
416
|
+
|
|
417
|
+
// …in the Environment's props:
|
|
418
|
+
egress: EgressPolicy.denyAll()
|
|
419
|
+
.applyPreset(github.egressPolicy()) // credentialed: the provider API + the git transport
|
|
420
|
+
.applyPreset(egressPreset.softwareDevelopment()), // anonymous: registries, public git reads, SaaS reads
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**A bundle is not a new concept.** Every entry lands as an ordinary allow rule, in the order you applied
|
|
424
|
+
them, so `plan.json`, `orchestra fdk diff` and the live environment show only rules — there is nothing about presets to
|
|
425
|
+
learn when reading a plan. Priorities arbitrate without you picking numbers: a preset row rides `100`, every
|
|
426
|
+
connection-bundle rule `300`, and a `deny` defaults to `900` — so a credentialed rule always wins a host a
|
|
427
|
+
preset row also opens, and a prohibition wins both. Customization is additive: author more rules beside a
|
|
428
|
+
bundle, and `deny(...)` anything you do not want.
|
|
429
|
+
|
|
430
|
+
**What `softwareDevelopment()` contains** is what ordinary development actually contacts: package registries
|
|
431
|
+
(npm, PyPI, Maven Central, Go, crates.io, the Gradle plugin portal and wrapper chain), container registries
|
|
432
|
+
(Docker Hub, GHCR), public git reads on GitHub, and read access to widely used development services
|
|
433
|
+
(Atlassian, Zendesk, PagerDuty, Datadog, Sentry, Slack, Notion, Statuspage, Linear). Every row is
|
|
434
|
+
`GET`/`HEAD` except three deliberate, path-scoped POSTs — git's `git-upload-pack` (fetch), Notion's
|
|
435
|
+
query/search, and Linear's `/graphql` — and **no row carries a credential**, so git *push* stays closed and
|
|
436
|
+
a service still needs whatever auth a connection bundle or a Secret-bound rule supplies. It takes no
|
|
437
|
+
arguments today, and each call returns fresh arrays, so two environments applying it share nothing.
|
|
438
|
+
|
|
439
|
+
**The connection bundle** is available identically on a managed `Connection` and on every import
|
|
440
|
+
(`fromSlug`/`fromId`/`fromEnv`), and it **supersedes a plain `allow(connection)`** — apply one or the other.
|
|
441
|
+
Rule names are connection-qualified, so two connections of the same provider can both hand one environment
|
|
442
|
+
their bundle. Its one option is `git` — `github.egressPolicy({ git: "read" })` — and it applies when the
|
|
443
|
+
provider declares a git transport:
|
|
444
|
+
|
|
445
|
+
| `git` | What it opens |
|
|
446
|
+
| --- | --- |
|
|
447
|
+
| `"readWrite"` (default) | clone/fetch **and** push — the connection's own authority, because the credential is the real write fence: a token without write scope cannot push whatever the network admits. |
|
|
448
|
+
| `"read"` | clone/fetch only. A push then fails at its *second* request — the `info/refs` discovery `GET` is a read and succeeds, then the push `POST` meets the policy's default deny as a 403. |
|
|
449
|
+
| `"none"` | no transport rules at all, narrowing **only this bundle** — an environment that also applies the anonymous preset keeps public git reads from that preset's own rows. |
|
|
450
|
+
|
|
451
|
+
Passing `git` to a provider that declares **no** transport is a synth error rather than a silent no-op. A
|
|
452
|
+
grant that needs its own hosts, paths, name or priority stays on `EgressPolicy.allow(connection, …)`, which
|
|
453
|
+
supports all four.
|
|
454
|
+
|
|
455
|
+
Two authoring notes:
|
|
456
|
+
|
|
457
|
+
- **Preset rule names are contract, not decoration.** Every row carries a fully-qualified
|
|
458
|
+
`software-development-*` name, and a bundle is a plain readonly array — so an app that wants a subset
|
|
459
|
+
filters it by name (`egressPreset.softwareDevelopment().filter((rule) => …)`) rather than the preset
|
|
460
|
+
growing a parameter. The names stay stable across releases for exactly that reason.
|
|
461
|
+
- **Every rule on one policy must have its own name.** Applying a preset over a rule you already named
|
|
462
|
+
`software-development-npm` is a **synth error** naming the duplicate — and so is applying the same bundle
|
|
463
|
+
twice, or applying a connection's bundle beside `allow(connection)`. Two rules under one name cannot apply
|
|
464
|
+
correctly (which one wins a request is decided by priority and declaration order, never by the name), so
|
|
465
|
+
the kit refuses instead of shipping the ambiguity.
|
|
466
|
+
|
|
467
|
+
Why the split is enforced rather than merely conventional — the bundle type has no credential field an app
|
|
468
|
+
could fill in, the priority bands above, and the platform matcher preferring a credentialed rule at an equal
|
|
469
|
+
priority — is in the architecture note `docs/architecture/fdk-agent-as-code.md`, § *Egress bundles*.
|
|
470
|
+
|
|
471
|
+
### Model traffic through the gateway: `routeModelsViaGateway()`
|
|
472
|
+
|
|
473
|
+
`EgressPolicy.denyAll().routeModelsViaGateway({ key })` lets the agent reach the platform's model gateway
|
|
474
|
+
with a gateway key it never holds. `key` is the `Secret` whose value is `SecretValue.fromGatewayKey(...)`
|
|
475
|
+
(next section):
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
egress: EgressPolicy.denyAll().allow(github).routeModelsViaGateway({ key: gatewayToken }),
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
It appends one ordinary allow rule, named `model-gateway` at priority `300`, on the gateway host. The
|
|
482
|
+
rule's binding adds `Authorization: Bearer <your gateway key>` at the egress edge and removes `x-api-key`,
|
|
483
|
+
so the agent calls the gateway with no key and the gateway swaps yours for the provider key.
|
|
484
|
+
|
|
485
|
+
The rule only adds the key. The agent learns the gateway's address from its own model settings, so set
|
|
486
|
+
`modelSettings: { enableModelGateway: true }` on each agent that routes through the gateway (§ *The model an
|
|
487
|
+
agent runs* below).
|
|
488
|
+
|
|
489
|
+
Four authoring notes:
|
|
490
|
+
|
|
491
|
+
- **The host defaults to the production gateway, `models.orchestraworks.ai`.** On any other deployment pass its host:
|
|
492
|
+
`routeModelsViaGateway({ key, host: "models.<your-deployment>" })`. It must be the host of the gateway
|
|
493
|
+
address your deployment gives agents, or no rule matches the address the agent dials. A `host` that is not
|
|
494
|
+
a bare hostname (blank, or carrying a scheme, a path, a port or a `*`) and a missing `key` are synth errors.
|
|
495
|
+
- **Do not tie priorities on the same host.** The name and priority are fixed: another rule named
|
|
496
|
+
`model-gateway` is a duplicate-name synth error, and if you *also* hand-author an allow rule over the
|
|
497
|
+
gateway host, give it a different priority. Higher priority is evaluated first; at an equal priority the
|
|
498
|
+
platform matcher silently takes the first match in declaration order.
|
|
499
|
+
- **Your organization needs secrets and managed egress.** Storing the key needs a plan with secrets, and a
|
|
500
|
+
rule carrying a credential needs managed egress; without them `apply` fails with a named plan error. An
|
|
501
|
+
organization on a plan without them, the individual plan among them, puts its gateway key into its
|
|
502
|
+
runtime itself, in environment variables or by hand; orchestra has no helper for that.
|
|
503
|
+
- **Calling a provider directly is a different thing, and it has no sugar.** If you want the agent to
|
|
504
|
+
talk straight to `api.anthropic.com` (or any other provider), write the ordinary rule for it and bind
|
|
505
|
+
*your* credential — `allow("anthropic", { hosts: ["api.anthropic.com"], priority: 200, credentials:
|
|
506
|
+
[header("x-api-key", myKey)] })`. That path uses your provider account, and it can coexist with the
|
|
507
|
+
gateway rule: the agent's model settings send its own traffic to the gateway, while the direct rule
|
|
508
|
+
covers whatever the agent addresses at the provider host itself (a CLI, a raw HTTP call). A direct
|
|
509
|
+
credential that never seems to fire is usually this, not a bug.
|
|
510
|
+
|
|
511
|
+
### Gateway keys (`GatewayKey`) and bring-your-own provider keys (`ModelProviderKey`)
|
|
512
|
+
|
|
513
|
+
`routeModelsViaGateway({ key })` takes the `Secret` holding a gateway key. A key and the `Secret` that
|
|
514
|
+
receives its token are declared as a **pair**:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
const modelGateway = new GatewayKey(factory, "model-gateway", {
|
|
518
|
+
slug: "factory-triage", // optional, as on every construct
|
|
519
|
+
displayName: "Factory triage", // optional; defaults to the construct id
|
|
520
|
+
monthlyCreditCap: 840, // optional; omitting it leaves a live cap unchanged, not uncapped
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const gatewayToken = new Secret(factory, "model-gateway-key", {
|
|
524
|
+
value: SecretValue.fromGatewayKey(modelGateway), // apply mints the key and writes its token here
|
|
525
|
+
});
|
|
526
|
+
// … then route through it: EgressPolicy.denyAll().routeModelsViaGateway({ key: gatewayToken })
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
A gateway key's token is shown **exactly once**, at mint, which is why the pair is mandatory: **exactly one**
|
|
530
|
+
`Secret` per key, since zero would discard the token and two would make the write ambiguous. Both are synth
|
|
531
|
+
errors, as is nesting `fromGatewayKey` inside another recipe. There is no importer (an existing key's token
|
|
532
|
+
is unrecoverable) and no in-place rotation — a revoked key permanently reserves its slug, so rotating means
|
|
533
|
+
declaring a **new slug**.
|
|
534
|
+
|
|
535
|
+
`ModelProviderKey` is the opposite direction — **BYOK**: your organization's own credential for one model
|
|
536
|
+
provider, which the gateway then uses upstream on your behalf.
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
new ModelProviderKey(factory, "fireworks", {
|
|
540
|
+
modelProvider: "fireworks", // the catalog provider — the real uniqueness key
|
|
541
|
+
credential: SecretValue.fromEnv("FIREWORKS_API_KEY"), // or fromCommandLine() to type it at apply (§6)
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
It is **not** a `Secret`: the credential is write-only, has no id to bind, cannot be injected at egress, and
|
|
546
|
+
leaves only through the gateway — so nothing in a factory references it (there is no `IModelProviderKey`). The
|
|
547
|
+
platform holds one active key per (organization, provider), so a declaration for a provider that already has
|
|
548
|
+
one **adopts** that row when the declared slug matches and refuses with a named error when it differs.
|
|
549
|
+
|
|
550
|
+
### The model an agent runs: `modelSettings`
|
|
551
|
+
|
|
552
|
+
An `Agent` declares the model it runs and the address its model calls go to. Two dials, no image build:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
new Agent(factory, "fixer", {
|
|
556
|
+
harness: "codex",
|
|
557
|
+
environment: runtime,
|
|
558
|
+
instructions: brief,
|
|
559
|
+
modelSettings: {
|
|
560
|
+
model: "accounts/fireworks/models/deepseek-v4p1-flash", // what the harness runs
|
|
561
|
+
enableModelGateway: true, // where it sends its model calls
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
- **`model`** is the model name the harness asks for. Omit it and the harness runs its own default.
|
|
567
|
+
- **`baseUrl`** is the origin the harness addresses, without a path (`https://models.example.com`, never
|
|
568
|
+
`https://models.example.com/` and never a path). Omit it and the harness reaches its vendor's own
|
|
569
|
+
endpoint.
|
|
570
|
+
- **`enableModelGateway: true`** fills `baseUrl` in with the gateway origin of the platform you are
|
|
571
|
+
applying to. That address is a property of the deployment, not of your factory, so this is the one way
|
|
572
|
+
to name it without writing it down. Declaring it *and* `baseUrl` is an apply-time error: one address.
|
|
573
|
+
|
|
574
|
+
**It names an address, never a credential.** The key the gateway authenticates you with is an ordinary
|
|
575
|
+
egress rule on the agent's environment — a `GatewayKey`, the `Secret` it mints into, and the rule
|
|
576
|
+
`routeModelsViaGateway({ key })` writes on the gateway host — and you declare that yourself, as you would any
|
|
577
|
+
other grant. The two halves are
|
|
578
|
+
independent: an address with no rule that allows it fails at the harness's own HTTP layer, like any other
|
|
579
|
+
denied destination.
|
|
580
|
+
|
|
581
|
+
Three things worth knowing before you declare one:
|
|
582
|
+
|
|
583
|
+
- **A `script` or `custom` agent takes no model settings**, because the platform configures no model for
|
|
584
|
+
either. A block with any member set on one is a `400`; an empty block is accepted on every harness and
|
|
585
|
+
means "none".
|
|
586
|
+
- **A change takes effect on the agent's next conversation**, not on one already running. The block is
|
|
587
|
+
versioned content, like `instructions`, so editing it mints a new agent version and the next placement
|
|
588
|
+
carries it.
|
|
589
|
+
- **Removing a member clears it.** `orchestra` sends the whole block on every apply and compares it in both
|
|
590
|
+
directions, so deleting `model` from your declaration is an `update` that clears the live value — unlike
|
|
591
|
+
most fields, where an omitted key means "leave it alone".
|
|
592
|
+
|
|
593
|
+
## 5. Synth → diff → apply
|
|
594
|
+
|
|
595
|
+
Run all three from the factory directory. The CLI never rebuilds the plan itself — it spawns your factory program and
|
|
596
|
+
reads back the artifacts it writes.
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
orchestra fdk synth # local only: no platform access
|
|
600
|
+
orchestra fdk diff # read-only: compares synth against live platform state
|
|
601
|
+
orchestra fdk apply # reconciles the platform to the synthesized desired state
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
- **`synth`** writes `fdk.out/{plan.json, graph.mmd, warnings.json}`. `plan.json` is
|
|
605
|
+
deterministic and value-free (stable key order, no ids/timestamps/secret values) — safe to commit and
|
|
606
|
+
review. Output:
|
|
607
|
+
`Synthesized 3 resource(s) and 0 import(s) to …/fdk.out`.
|
|
608
|
+
- **`diff`** prints per-resource states — `create` / `update` / `refused` / `no-change`, plus
|
|
609
|
+
`value-write-pending` for a declared secret whose source env var is set, or for a
|
|
610
|
+
`SecretValue.fromCommandLine` source on a resource that **already exists** (an absent one is simply
|
|
611
|
+
`create`). A CLI value is unreadable outside the apply prompt, so a metadata-level `no-change` must
|
|
612
|
+
not hide a pending overwrite. That is followed by an informational orphan report (namespace siblings
|
|
613
|
+
absent from the plan; never deleted). It mutates nothing and **never prompts**: a CLI-sourced
|
|
614
|
+
credential is reported, not asked for, and is never an error under `diff`, headless runs included.
|
|
615
|
+
`refused` is a project-identity conflict: the live row sits in a different project than the one
|
|
616
|
+
declared, and because a placement is identity (like a slug) orchestra never moves it — `apply` stops at
|
|
617
|
+
that resource. The printed message names the remedies for that kind: keep the live placement and
|
|
618
|
+
*reference* the row instead of declaring it (§7, for the kinds that have an importer), or redeclare it
|
|
619
|
+
under a new slug in the project you intended. Gateway keys, workflows and triggers have no
|
|
620
|
+
importer, so theirs are the new slug or dropping the declaration and leaving the live row untouched; a
|
|
621
|
+
conflicted gateway key and its paired secret are redeclared under new slugs together or dropped
|
|
622
|
+
together, and the paired secret is never imported. A new slug is a **replacement, not a move** — it
|
|
623
|
+
creates a fresh row in the intended project and leaves the existing one untouched where it is.
|
|
624
|
+
|
|
625
|
+
```
|
|
626
|
+
Target organization: org_…
|
|
627
|
+
organization slug: acme
|
|
628
|
+
create secret acme-support-openai-api-key
|
|
629
|
+
create environment acme-support-runtime
|
|
630
|
+
create agent acme-support-agent
|
|
631
|
+
Diff: 3 to create, 0 to update, 0 refused, 0 secret value write(s) pending, 0 unchanged (synth in …).
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
- **`apply`** prints the org banner, gates any secret writes behind one consolidated confirmation
|
|
635
|
+
(see §6), then reconciles in dependency order — the `PLAN_KINDS` order (project → secrets →
|
|
636
|
+
connectors → connections → environments → skills → agents), followed by workflows and then
|
|
637
|
+
triggers — and writes `fdk.out/result.json`: the resolved `organization`, a `resources` array
|
|
638
|
+
of `{ path, kind, slug, id, action }`, and value-free `verificationHints`.
|
|
639
|
+
- **Declared-image build phase.** If the plan declares `Image.fromBuild` images, `apply` builds and
|
|
640
|
+
pushes them **before** reconcile — it is the only docker-touching command (`synth`/`diff` never touch
|
|
641
|
+
docker). It probes the daemon (`docker info`), builds each image in declaration order, and pushes
|
|
642
|
+
every ref containing a `/` (a single-segment `foo:local` is local-only and skipped); a push is skipped
|
|
643
|
+
when a local digest proves the remote tag is already up to date. On a push auth failure it offers an
|
|
644
|
+
interactive `docker login` recovery on a TTY (credential via stdin, never stored or echoed) and
|
|
645
|
+
retries once. A build/push failure aborts before any platform write; already-pushed images stay
|
|
646
|
+
published. Pass **`--no-docker-build`** to skip the phase entirely (CI that only reconciles a
|
|
647
|
+
pre-published tag, or a docker-less host) — otherwise a missing daemon is a loud failure naming the
|
|
648
|
+
flag. A factory with no `fromBuild` images never invokes docker.
|
|
649
|
+
|
|
650
|
+
```
|
|
651
|
+
Applied: 3 created, 0 updated, 0 unchanged.
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
- **`plan.json` is preview-only.** `apply` always re-synthesizes from code and deletes any stale on-disk
|
|
655
|
+
plan first — a committed or hand-edited plan is never trusted as state. Re-running `apply` with nothing
|
|
656
|
+
changed reports **all unchanged** (reconciliation is idempotent). A green apply does *not* prove runtime
|
|
657
|
+
viability (e.g. image pullability) — verify with an end-to-end machine smoke.
|
|
658
|
+
|
|
659
|
+
## 6. Secrets
|
|
660
|
+
|
|
661
|
+
A declaration never carries a secret value. It carries a **recipe** naming where the value will come from,
|
|
662
|
+
and there are three kinds of source: read from the environment at apply (`fromEnv`, byte-for-byte with no
|
|
663
|
+
trimming), typed into the terminal at apply (`fromCommandLine`), or minted by the platform itself
|
|
664
|
+
(`fromGatewayKey`, below). The plan carries only the recipe — the env-var *name*, the bare fact that a value
|
|
665
|
+
is CLI-supplied, or the declared key's reference — never a value.
|
|
666
|
+
|
|
667
|
+
**Secret bytes are never serialized:** not in plan.json, graph.mmd, diff, apply logs, result.json, errors,
|
|
668
|
+
or stack traces. They are never printed **in full** either, with one deliberate exception worth knowing
|
|
669
|
+
about: when you type a credential at the `fromCommandLine` prompt, the reader shows the **first 4
|
|
670
|
+
characters** back to you once you press Enter (and only if what you typed is at least 8 characters long).
|
|
671
|
+
Those 4 characters are on your screen, so a shell transcript, a CI log wrapper or a screen recording
|
|
672
|
+
captures them. That is the only place any part of a secret value reaches a terminal.
|
|
673
|
+
|
|
674
|
+
### Choosing a source: `fromEnv` vs `fromCommandLine`
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
new Secret(factory, "openai", {
|
|
678
|
+
value: SecretValue.fromEnv("OPENAI_API_KEY_VALUE"), // exported by you (or CI) before apply
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
new Secret(factory, "deepseek", {
|
|
682
|
+
value: SecretValue.fromCommandLine(), // apply asks you to type it
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
new Secret(factory, "anthropic", {
|
|
686
|
+
value: SecretValue.fromCommandLine("the org's Anthropic key"), // optional label shown in the prompt
|
|
687
|
+
});
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Pick `fromEnv` when a machine supplies the value (CI, a secret manager, your shell profile). Pick
|
|
691
|
+
`fromCommandLine` when a **human** holds the credential and you never want it sitting in an environment
|
|
692
|
+
variable. The declaration says which, so the same factory behaves the same way on every machine — that is
|
|
693
|
+
the whole point of the split.
|
|
694
|
+
|
|
695
|
+
**`fromEnv` is strict.** A declared env var that is unset, empty, or whitespace-only is an **error** at
|
|
696
|
+
apply, naming the declaration and the variable, identically on your laptop and in CI. Earlier versions of
|
|
697
|
+
orchestra fell back to an interactive prompt when the variable happened to be unset and stdin was a terminal;
|
|
698
|
+
that fallback is **gone**. If you want to be asked, say so with `fromCommandLine`.
|
|
699
|
+
|
|
700
|
+
| Source env var | Secret on platform | Behavior |
|
|
701
|
+
| --- | --- | --- |
|
|
702
|
+
| set (non-empty) | absent | confirm → create |
|
|
703
|
+
| set (non-empty) | exists | **warn + explicit approval** → overwrite the live value |
|
|
704
|
+
| unset, empty, or whitespace-only | absent | **error** naming the declaration and the variable — on a TTY and headless alike |
|
|
705
|
+
| unset, empty, or whitespace-only | exists | **no-op** — the live value is left alone |
|
|
706
|
+
| **some but not all** parts of a composed value available | any | **error** before any read, so no half-value is composed |
|
|
707
|
+
|
|
708
|
+
**Strictness is scoped to writes that actually need the value.** An unset variable fails a *create*,
|
|
709
|
+
because the declaration promises a value that is not there. It does **not** fail an apply whose secret
|
|
710
|
+
already exists — that stays a clean no-op, so a factory can be applied from a shell that never exported
|
|
711
|
+
the credential, and "leave the live value alone" remains expressible.
|
|
712
|
+
|
|
713
|
+
That last table row is about supplying a value *half way*, and it applies to any composed recipe — an
|
|
714
|
+
`ecrCredential` with one of its two variables exported, or one typed leaf beside an unset variable.
|
|
715
|
+
Exporting **none** of them is the no-op above ("leave the live value alone"); exporting **some** of them
|
|
716
|
+
is an error, because the alternative is an apply that reports "unchanged" while quietly keeping the
|
|
717
|
+
value you thought you had just rotated.
|
|
718
|
+
|
|
719
|
+
**Replacing a live value warns and needs explicit approval.** When the variable *is* set and the secret
|
|
720
|
+
already exists, apply overwrites what is stored — so that write is listed in its own block naming each
|
|
721
|
+
credential whose live value is replaced, and it takes an affirmative of its own. `--approve-secrets` (or
|
|
722
|
+
`ORCHESTRA_APPROVE_SECRETS=true`) pre-approves it for automation; a non-interactive apply carrying such a write
|
|
723
|
+
without it refuses by name rather than proceeding. A credential whose value is **entirely**
|
|
724
|
+
`fromCommandLine` is exempt: it is overwritten every apply by design and you are typing the value at the
|
|
725
|
+
prompt, so typing it is the confirmation. A composed recipe that mixes `fromCommandLine` with `fromEnv`
|
|
726
|
+
is **not** exempt — you are typing one part while a variable supplies the other, and typing your part
|
|
727
|
+
says nothing about that one, so it keeps the warning and the second question.
|
|
728
|
+
|
|
729
|
+
**The optional label is display text only.** `fromCommandLine("the org's Anthropic key")` puts that text on
|
|
730
|
+
the prompt line in place of the structural description; with no label the prompt names the resource itself.
|
|
731
|
+
Synth rejects a label that is blank, spans more than one line, is longer than 80 characters, or contains
|
|
732
|
+
`${` (the recipe is serialized into the plan, and the graph renderer scans recipes for `${ref:…}` tokens, so
|
|
733
|
+
an unconstrained label could draw a fake edge to a real resource).
|
|
734
|
+
|
|
735
|
+
**Where a CLI value is asked for, and what you see.** All CLI-sourced credentials are collected in one pass
|
|
736
|
+
that runs **after** the consolidated confirmation below and before the first credential write:
|
|
737
|
+
|
|
738
|
+
```
|
|
739
|
+
Secret 2 of 3 — deepseek-api-key (the org's DeepSeek key)
|
|
740
|
+
value: sk-6••••••••••••••••••••••••••••
|
|
741
|
+
✓ captured (32 chars)
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- Every character **masks while you type**. When you press Enter the line is redrawn with the first 4
|
|
745
|
+
characters visible — but only if the value is at least 8 characters; anything shorter stays fully masked,
|
|
746
|
+
so a short credential is never shown in full. The prefix is there so you can tell *which* credential you
|
|
747
|
+
just typed (`sk-`, `ghp_`, `xoxb`) without revealing it.
|
|
748
|
+
- **Everything the prompt writes goes to stderr** — the heading, the `•` echoes, the 4-character
|
|
749
|
+
reveal, the `✓ captured` line. stdout is the command's result channel, so redirecting it
|
|
750
|
+
(`orchestra secrets create --value-from-command-line --output json > key.json`) captures the response
|
|
751
|
+
document alone, never the prompt or the reveal.
|
|
752
|
+
- `N of M` counts individual values, not resources: a composed recipe with two CLI-sourced parts (an
|
|
753
|
+
`ecrCredential`, say) asks twice, access key first.
|
|
754
|
+
- Blank or whitespace-only input re-prompts, up to 3 attempts, then fails naming the resource. Ctrl-C or
|
|
755
|
+
EOF aborts before any credential is written.
|
|
756
|
+
- **Declining the confirmation asks you for nothing at all** — the prompts come after that gate, so a "no"
|
|
757
|
+
never makes you type a secret for a write that will not happen.
|
|
758
|
+
- **A headless apply fails with a named error** — "this resource declares an interactive credential", not
|
|
759
|
+
"environment variable unset". `fromCommandLine` needs a terminal by construction; use `fromEnv` in CI.
|
|
760
|
+
- **Every apply re-prompts every CLI-sourced credential.** That is deliberate, and it is the rotation
|
|
761
|
+
path: retyping the credential rotates it, and there is no flag to discover. (An env-sourced secret works
|
|
762
|
+
the same way — it is overwritten on every apply where its variable is set.)
|
|
763
|
+
|
|
764
|
+
`fromCommandLine` is legal anywhere `fromEnv` is legal, including nested inside `basicAuth`,
|
|
765
|
+
`registryCredential` and `ecrCredential`, and as a `ModelProviderKey` credential.
|
|
766
|
+
|
|
767
|
+
**Where a minted value comes from.** `SecretValue.fromGatewayKey(key)` is the one source whose value the
|
|
768
|
+
platform produces instead of you. A gateway-key-backed Secret therefore has no env var, no prompt and no row
|
|
769
|
+
in the table above: at apply the declared `GatewayKey` (§4) is minted and the token it shows **exactly
|
|
770
|
+
once** is written straight into its paired Secret, by a protocol that marks the Secret *before* minting so
|
|
771
|
+
an interrupted run is detectable rather than silently stranding a token you can never see again. A
|
|
772
|
+
`ModelProviderKey` (§4) writes a credential too — from an ordinary `fromEnv` or `fromCommandLine` recipe —
|
|
773
|
+
but mints no `Secret` at all.
|
|
774
|
+
|
|
775
|
+
Pending writes are confirmed with **one consolidated value-free table and a `y/N` prompt**, covering all
|
|
776
|
+
three credential kinds: ordinary secrets, gateway-key mints, and model-provider-key credentials. When that
|
|
777
|
+
plan also **replaces the live value** of an existing env-sourced credential, a second `WARNING:` block
|
|
778
|
+
lists those replacements and takes an affirmative of its own — so there are two prompts, and the
|
|
779
|
+
destructive one is answered separately from the routine one. For automation, pass `--approve-secrets` or set `ORCHESTRA_APPROVE_SECRETS=true` — it only suppresses
|
|
780
|
+
the prompt, never widens what is written. Without it, an apply that has pending writes and **no terminal**
|
|
781
|
+
fails by name, listing the writes it did not make: a prompt nobody can answer is refused, never waited on. An apply that declares no `GatewayKey`, no `ModelProviderKey` and
|
|
782
|
+
no CLI source, and has no secret env vars set, never touches a credential value at all. A CLI-sourced write
|
|
783
|
+
shows up in that table before you are asked to type anything, and it travels the same value-safe channel as
|
|
784
|
+
an env value (request body only — never logged, serialized, in `result.json`, or in the plan snapshot),
|
|
785
|
+
behind the same TLS transport guard, which refuses a credential write over non-loopback plain HTTP.
|
|
786
|
+
|
|
787
|
+
Derived credentials: `SecretValue.basicAuth("x-access-token", tokenValue)` renders
|
|
788
|
+
`base64("x-access-token:<token>")` (the base64 derivation happens only at apply). Bind a rendered value to
|
|
789
|
+
an egress header with `bearer(header, secret)`, `basic(header, secret)`, or `header(name, secret)` — the
|
|
790
|
+
raw binder, which injects the secret value **verbatim** (no `Bearer `/`Basic ` scheme prefix), for a
|
|
791
|
+
destination that wants the credential in a bare custom header.
|
|
792
|
+
|
|
793
|
+
### Injection recipes: several headers from one secret
|
|
794
|
+
|
|
795
|
+
Those three binders are all one-header-per-secret. A destination that needs a **set** of headers derived
|
|
796
|
+
from one credential — or that needs a client-sent header **stripped** — declares an `injection` recipe on
|
|
797
|
+
the Secret itself and binds it by passing the bare `Secret` as the credential (no binder call — the
|
|
798
|
+
recipe supplies the headers):
|
|
799
|
+
|
|
800
|
+
The **Claude sign-in recipe** is the one to copy when the claude harness runs on a person's Claude
|
|
801
|
+
subscription token rather than an API key:
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
import { EgressPolicy, Secret, SecretValue } from "@orchestraworks/cli";
|
|
805
|
+
|
|
806
|
+
const claudeSignIn = new Secret(factory, "claude-sign-in", {
|
|
807
|
+
value: SecretValue.fromEnv("SFDK_CLAUDE_CODE_OAUTH_TOKEN"),
|
|
808
|
+
injection: {
|
|
809
|
+
headers: [{ name: "authorization", value: "Bearer ${secret}" }],
|
|
810
|
+
removeHeaders: ["x-api-key"], // stripped before injection, so a runtime placeholder cannot leak
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// …in the Environment's props:
|
|
815
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: "placeholder", ANTHROPIC_API_KEY: "" },
|
|
816
|
+
egress: EgressPolicy.allowAll().allow("claude-sign-in", {
|
|
817
|
+
hosts: ["api.anthropic.com"],
|
|
818
|
+
priority: 300,
|
|
819
|
+
credential: claudeSignIn, // the bare Secret — its injection recipe supplies the headers
|
|
820
|
+
}),
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
It injects `authorization` and nothing else. **Never inject an `anthropic-beta` header**: an injected one
|
|
824
|
+
replaces the harness's own list of beta flags, and every call then answers 400. The placeholder
|
|
825
|
+
`CLAUDE_CODE_OAUTH_TOKEN` makes the harness sign in the subscription way and send its own list, and the edge
|
|
826
|
+
swaps the real token in. The empty `ANTHROPIC_API_KEY` covers the runtime image's placeholder key, which the
|
|
827
|
+
harness would otherwise send instead.
|
|
828
|
+
|
|
829
|
+
- **`headers[].value`** templates take `${secret}` (the whole value) or `${secret.<field>}` (a top-level
|
|
830
|
+
field of a JSON-object secret value); a literal-only value is fine for companion headers.
|
|
831
|
+
- **`removeHeaders`** strips client-sent headers before injection; injecting a header the client also
|
|
832
|
+
sent replaces it.
|
|
833
|
+
- Synth mirrors the platform's write-time rules: header-name charset, the routing/hop-by-hop denylist
|
|
834
|
+
(`host`, `content-length`, `transfer-encoding`, `connection`, `te`, `upgrade`, any `proxy-*`), the two
|
|
835
|
+
placeholder forms, and the caps (≤ 8 header ops, ≤ 8 removals).
|
|
836
|
+
|
|
837
|
+
The **binding determines application**: a legacy `bearer`/`basic`/`header` binding over a recipe-carrying
|
|
838
|
+
secret is a valid, explicit choice to present that secret as one header (its recipe is not applied on that
|
|
839
|
+
rule). The one remaining synth error is passing a recipe-**less** secret **bare** — there is no recipe to
|
|
840
|
+
apply, so bind it with `bearer`/`basic`/`header` or an `inject()` override instead. `inject(secret,
|
|
841
|
+
recipe)` binds any secret (recipe-carrying or not) under a **binding-owned** recipe that overrides the
|
|
842
|
+
secret's own recipe wholesale — the recipe is validated once at synth and deep-copied into the plan, so a
|
|
843
|
+
later mutation of your input cannot smuggle an unvalidated header. A bare recipe-less check only fires for
|
|
844
|
+
**locally declared** secrets; a `Secret.fromSlug`/`fromId` import carries no visible recipe, so it defers
|
|
845
|
+
to the server.
|
|
846
|
+
|
|
847
|
+
## 7. Work with existing resources
|
|
848
|
+
|
|
849
|
+
Reference platform resources you don't manage — they resolve at apply and are never created or mutated:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
Secret.fromSlug(factory, "shared-openai-key");
|
|
853
|
+
Secret.fromId(factory, "secret_…");
|
|
854
|
+
Agent.fromSlug(factory, "existing-agent");
|
|
855
|
+
Environment.fromSlug(factory, "shared-runtime");
|
|
856
|
+
Volume.fromSlug(factory, "cache-vol");
|
|
857
|
+
Volume.fromId(factory, "volume_…");
|
|
858
|
+
Connection.fromEnv(factory, "SFDK_ZENDESK_CONNECTION_ID", { provider: Providers.Zendesk });
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
`Connection.fromEnv` resolves the connection id from `process.env` at apply, so **no tenant id
|
|
862
|
+
ever lands in a committed plan** (use it instead of `fromId` when the plan is committed). Imports validate
|
|
863
|
+
at apply: a connection must be `ACTIVE`, and its live provider (plus any declared `install` values) must
|
|
864
|
+
match the declared `{ provider, install? }`; a missing import is a 404 naming the reference source. A secret
|
|
865
|
+
reference that looks like a **raw credential** (fails the id/slug shape allowlist, or matches a
|
|
866
|
+
`sk-`/`ghp_`/`Bearer …` denylist) is **rejected at synth**, before it can be echoed anywhere.
|
|
867
|
+
|
|
868
|
+
### Connections, triggers, and workflows
|
|
869
|
+
|
|
870
|
+
Install an external provider as a managed `Connection` (from the generated `Providers.*` catalog), route its
|
|
871
|
+
events with standalone `Trigger` rows — the ONE routing mechanism — and grant the runtime outbound access
|
|
872
|
+
with `env.egressPolicy.allow(connection)`.
|
|
873
|
+
|
|
874
|
+
Those are the **three statements that wire a connection**, and authoring all three is the model:
|
|
875
|
+
|
|
876
|
+
| | Statement | What it is |
|
|
877
|
+
|---|---|---|
|
|
878
|
+
| **1. Connection** | `new Connection(factory, …)` | the identity: provider, auth, install, `autoRespond` |
|
|
879
|
+
| **2. Inbound** | `new Trigger(factory, …)` | events → agent or pipeline — **what the agent hears** |
|
|
880
|
+
| **3. Outbound** | `egress.allow(connection)` | agent → provider — **what the agent may say** |
|
|
881
|
+
|
|
882
|
+
Every connection is wired twice — inbound is what the agent hears, outbound is what the agent may say — and
|
|
883
|
+
both statements are yours. Nothing derives the second from the first, and only the third is invisible when
|
|
884
|
+
forgotten: everything synths, applies and routes, and then the agent's reply gets a 403 from the egress fence
|
|
885
|
+
inside its own machine. Synth warns on that shape (below), but the grant is always authored.
|
|
886
|
+
|
|
887
|
+
The outbound statement has a fuller form: `egress.applyPreset(connection.egressPolicy())` folds the whole
|
|
888
|
+
surface the provider catalog declares for that connection — the provider API *and* its git transport — on in
|
|
889
|
+
one call, and supersedes `allow(connection)` (§4).
|
|
890
|
+
|
|
891
|
+
A trigger is flat: `{ on: {connection, events[]} | {schedule}, where[], target }`. The **target** names a
|
|
892
|
+
definition (`Targets.agent` or `Targets.pipeline`, never an instance) plus a **binding** — one render template
|
|
893
|
+
over the raw event payload whose value is the holder key. Equal keys share a holder, so a conversation or a
|
|
894
|
+
work item tracks one provider item for its whole life:
|
|
895
|
+
|
|
896
|
+
```ts
|
|
897
|
+
import {
|
|
898
|
+
Auth, Connection, Pipeline, Providers, Targets, Trigger, Workflow,
|
|
899
|
+
activity, any, assign, check, created, field, message, move, not, rule, scoped, toAssignee,
|
|
900
|
+
} from "@orchestraworks/cli";
|
|
901
|
+
import { Jira, Events as JiraEvents, Bindings as JiraBindings } from "@orchestraworks/cli/jira";
|
|
902
|
+
|
|
903
|
+
const jira = new Connection(factory, "jira-main", {
|
|
904
|
+
provider: Providers.Jira,
|
|
905
|
+
auth: Auth.oauth(), // or customerManagedToken(secret) / webhookSecret() / githubApp() / forgeApp()
|
|
906
|
+
install: { projectKey: "SF" }, // the generated per-provider install type
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// One conversation per Jira issue: created/updated/commented all render the same key, so the agent keeps
|
|
910
|
+
// one session per issue (establish → steer at the turn boundary → wake). `binding` is omitted because the
|
|
911
|
+
// selection's canonical binding (JiraBindings.Issue) already covers it — it materializes into the wire at
|
|
912
|
+
// synth, so `plan.json` shows the truth and the platform never infers.
|
|
913
|
+
new Trigger(factory, "jira-worker", {
|
|
914
|
+
on: { connection: jira, events: [JiraEvents.IssueCreated, JiraEvents.IssueUpdated, JiraEvents.CommentCreated] },
|
|
915
|
+
target: Targets.agent(fixer, {
|
|
916
|
+
successorContext: "SUMMARY",
|
|
917
|
+
prompt: "Work Jira issue {{event.issue.key}}. Read and update it via the Jira API.",
|
|
918
|
+
}),
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// Work that moves through stages is a pipeline of work items; a workflow is that pipeline's rules. Each rule
|
|
922
|
+
// is named for what it does, verb first.
|
|
923
|
+
const triage = new Pipeline(factory, "triage", {
|
|
924
|
+
itemSlugPrefix: "tri",
|
|
925
|
+
stages: [
|
|
926
|
+
{ slug: "fixing", category: "IN_PROGRESS" },
|
|
927
|
+
{ slug: "review", category: "IN_PROGRESS" },
|
|
928
|
+
{ slug: "done", category: "DONE", requires: [{ field: "checks.review.result", op: "is", value: "PASS" }] },
|
|
929
|
+
],
|
|
930
|
+
});
|
|
931
|
+
new Workflow(factory, "triage-flow", {
|
|
932
|
+
pipeline: triage,
|
|
933
|
+
rules: [
|
|
934
|
+
rule({ name: "Assign the fixer on arrival", when: created(), then: [assign({ agent: fixer })] }),
|
|
935
|
+
rule({
|
|
936
|
+
name: "Relay each comment to the assignee",
|
|
937
|
+
when: activity("INBOUND_EVENT"),
|
|
938
|
+
then: [message(toAssignee(), "{{event.payload.comment.body}}")],
|
|
939
|
+
}),
|
|
940
|
+
rule({ name: "Send a rejected fix back to Fixing", when: check("review").is("FAIL"), then: [move("fixing")] }),
|
|
941
|
+
],
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// A pipeline-target trigger creates one work item per key. `where` narrows admission only — it never touches
|
|
945
|
+
// the key. Its entries AND together; `any(...)`, `all(...)` and `not(...)` nest a group, and `and(...)` inside
|
|
946
|
+
// one groups its parts.
|
|
947
|
+
new Trigger(factory, "start-triage", {
|
|
948
|
+
on: { connection: jira, events: [JiraEvents.IssueCreated] },
|
|
949
|
+
where: [
|
|
950
|
+
field("issue.fields.priority.name").oneOf(["Highest", "High"]),
|
|
951
|
+
any(field("issue.fields.labels").has("customer"), not(field("issue.fields.assignee").exists())),
|
|
952
|
+
],
|
|
953
|
+
target: Targets.pipeline(triage, {
|
|
954
|
+
binding: JiraBindings.Issue,
|
|
955
|
+
onMissing: "START_NEW",
|
|
956
|
+
title: "{{event.payload.issue.fields.summary}}",
|
|
957
|
+
}),
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// The composition pattern: a feed-only row delivers comments to the item already holding that key, and drops
|
|
961
|
+
// the delivery when there is none.
|
|
962
|
+
new Trigger(factory, "triage-comments", {
|
|
963
|
+
on: { connection: jira, events: [JiraEvents.CommentCreated] },
|
|
964
|
+
target: Targets.pipeline(triage, { binding: JiraBindings.Issue, onMissing: "SKIP" }),
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// Grant the runtime outbound access to the provider; hosts come from the provider's apiHosts
|
|
968
|
+
// (install-parameterized), the credential is broker-resolved from the connection.
|
|
969
|
+
const env = new Environment(factory, "runtime", { /* … */ egress: EgressPolicy.denyAll().allow(jira) });
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**Binding authoring is three all-static layers** — no lambdas, no tagged templates:
|
|
973
|
+
|
|
974
|
+
| Layer | Example | When |
|
|
975
|
+
|---|---|---|
|
|
976
|
+
| generated canonical | `JiraBindings.Issue` | the provider package's key for a resource family |
|
|
977
|
+
| composition | `scoped(field("issue.key"))`, `key(connection, ":", ref)` | a key the canonical one does not express |
|
|
978
|
+
| raw template | `"{{connection.slug}}:{{event.payload.issue.key}}"` | anything else; always valid |
|
|
979
|
+
|
|
980
|
+
A generated binding is typed to the events that can render it, so using one across payload shapes that do not
|
|
981
|
+
all carry its field is a **compile error**, not a silent misgroup. The remedy is the composition pattern above:
|
|
982
|
+
split into per-shape triggers sharing one binding constant and one anchor. `{{connection.slug}}` is authorial
|
|
983
|
+
— include it to scope keys per connection (what `scoped(...)` and every generated binding do), omit it
|
|
984
|
+
deliberately to join work across connections.
|
|
985
|
+
|
|
986
|
+
Verb defaults all materialize into the wire at synth: an agent target's `onMissing: START_NEW`, `onBusy:
|
|
987
|
+
STEER` and `successorContext: NONE`, and a pipeline target's `onMissing: START_NEW`, `onOpen: DELIVER`,
|
|
988
|
+
`onClosed: SKIP` and `onBusy: STEER`.
|
|
989
|
+
|
|
990
|
+
**The authorize handoff.** An `Auth.oauth()` connection is created `NEEDS_ATTENTION` — the factory holds no
|
|
991
|
+
provider credential, and the consent dance is a human's browser session, not something apply can perform. So
|
|
992
|
+
apply reconciles the full dependent graph first and then, for every managed or imported OAuth connection still awaiting
|
|
993
|
+
authorization, prints one non-fatal line:
|
|
994
|
+
|
|
995
|
+
```
|
|
996
|
+
connection 'workspace-slack' needs attention — authorize at: https://…/connections/conn_…?organizationId=org_…
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
That printed line **is** the human step: open it, complete consent, and re-run `apply`. Apply itself always
|
|
1000
|
+
exits successfully — an unauthorized connection is a pending handoff, not a failure — and re-running is a
|
|
1001
|
+
no-op for everything already reconciled.
|
|
1002
|
+
|
|
1003
|
+
**App-established connections (`Auth.githubApp()`, `Auth.forgeApp()`).** Two modes are **mode-only**: they
|
|
1004
|
+
take no options and carry no credential of yours, because the identity is the platform's own GitHub App or
|
|
1005
|
+
Atlassian Forge app, whose credential is minted server-side per request. What replaces the consent dance is a
|
|
1006
|
+
**provider-side installation ceremony** — so a from-zero `Auth.githubApp()` connection cannot simply be
|
|
1007
|
+
created (its installation id does not exist yet). Apply mints an **install link** instead, prints it, and
|
|
1008
|
+
waits at that connection's own step, because everything downstream resolves the connection's id:
|
|
1009
|
+
|
|
1010
|
+
```
|
|
1011
|
+
connection 'github': needs a provider-side installation before it can be created.
|
|
1012
|
+
1. Open this link and complete the installation:
|
|
1013
|
+
https://<platform>/integrations/install/…
|
|
1014
|
+
This link is shown ONCE and cannot be reprinted. It stays usable until
|
|
1015
|
+
2026-08-25 09:30 UTC, so you can hand it to whoever administers the provider account.
|
|
1016
|
+
2. Leave this apply running — it waits up to 10m and then continues.
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
The link is long-lived (days, not minutes) precisely because the person who runs `apply` is usually not the
|
|
1020
|
+
person who administers the GitHub account: send it on, and it still works next week.
|
|
1021
|
+
|
|
1022
|
+
**Re-running is reuse-first.** A later `apply` that finds a link still pending for the same connection
|
|
1023
|
+
**reports it instead of minting another one**, because minting revokes the pending one — which would break
|
|
1024
|
+
the URL a delegated administrator is still holding:
|
|
1025
|
+
|
|
1026
|
+
```
|
|
1027
|
+
connection 'github': an install link for this connection is already pending and is valid until
|
|
1028
|
+
2026-08-25 09:30 UTC — whoever you sent it to can still use it, so this apply did not mint a new one.
|
|
1029
|
+
1. Ask the provider's administrator to open the link they were sent and complete the installation.
|
|
1030
|
+
The link's URL is shown only when it is minted and is never recoverable, so it cannot be
|
|
1031
|
+
reprinted here. If it was lost, re-run `orchestra fdk apply --remint-install-links`: that revokes this
|
|
1032
|
+
link and prints a fresh one.
|
|
1033
|
+
2. Leave this apply running — it waits up to 10m and then continues.
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
Pass **`--remint-install-links`** when the link was lost, leaked, or sent to the wrong person: it mints a
|
|
1037
|
+
fresh link (revoking the pending one) for every App-established connection that apply reconciles.
|
|
1038
|
+
|
|
1039
|
+
The wait ends after 10 minutes or at the link's own expiry, whichever comes first, and checks every 5
|
|
1040
|
+
seconds. **`--install-timeout <seconds>`** changes the bound (`0` checks once, then stops with the resume
|
|
1041
|
+
message), and **`--install-poll-interval <seconds>`** changes how often it checks.
|
|
1042
|
+
|
|
1043
|
+
Finish the installation and the same apply adopts the connection at its declared slug and carries on. On a
|
|
1044
|
+
timeout nothing downstream was applied, and the message says to re-run `apply` after installing — the re-run
|
|
1045
|
+
adopts the connection and applies the rest. Authorable install fields ride `install` as usual
|
|
1046
|
+
(`install: { permissions: ["issues:write"] }`); declaring a real `installationId` opts back into the ordinary
|
|
1047
|
+
direct create, for an App you have already installed. A connection that installs your organization's **own**
|
|
1048
|
+
registered App (`Connector`, below) mints its link against that registration, not the platform's.
|
|
1049
|
+
|
|
1050
|
+
### Bring your own OAuth app (`Connector`)
|
|
1051
|
+
|
|
1052
|
+
By default a `Connection` installs through the **platform's** registered provider app. A `Connector` declares
|
|
1053
|
+
your organization's **own** app registration instead — one registered app that many connections ride, with a
|
|
1054
|
+
single place to rotate its material. Every secret-classed field takes an org `Secret` **reference**; no
|
|
1055
|
+
credential material ever enters a declaration or `plan.json`:
|
|
1056
|
+
|
|
1057
|
+
```ts
|
|
1058
|
+
import { Auth, Connection, Connector, Providers, Secret, SecretValue } from "@orchestraworks/cli";
|
|
1059
|
+
|
|
1060
|
+
const ourSlackApp = new Connector(factory, "our-slack-app", {
|
|
1061
|
+
provider: Providers.Slack,
|
|
1062
|
+
clientId: "1234.5678",
|
|
1063
|
+
appId: "A0ACME",
|
|
1064
|
+
clientSecret: new Secret(factory, "slack-client-secret", { value: SecretValue.fromEnv("SFDK_SLACK_CLIENT_SECRET") }),
|
|
1065
|
+
signingSecret: new Secret(factory, "slack-signing-secret", { value: SecretValue.fromEnv("SFDK_SLACK_SIGNING_SECRET") }),
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const workspace = new Connection(factory, "workspace", {
|
|
1069
|
+
provider: Providers.Slack,
|
|
1070
|
+
auth: Auth.oauth({ connector: ourSlackApp }), // omit `connector` to use the platform app
|
|
1071
|
+
install: {},
|
|
1072
|
+
});
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
Which fields a provider requires is catalog knowledge the **platform** owns: apply rejects a field the target
|
|
1076
|
+
provider does not declare, and rejects a declared field left unsupplied. Import a pre-existing registration
|
|
1077
|
+
(including the platform's own) with `Connector.fromSlug` / `Connector.fromId`.
|
|
1078
|
+
|
|
1079
|
+
Three rules the CLI surfaces for you:
|
|
1080
|
+
|
|
1081
|
+
- **Registering a custom app needs the `flags.customConnectorApps` entitlement.** Synth cannot check it (the
|
|
1082
|
+
plan names no organization), so declaring a `Connector` emits a warning and `apply` is the authoritative
|
|
1083
|
+
gate — a `403` plan-feature denial when the target org lacks it.
|
|
1084
|
+
- **A registration's identity is create-only, but its configuration is not.** Which is which comes from the
|
|
1085
|
+
provider catalog, per field. An *identity* field — `clientId`, `appId`, `botAppId`, `botId`, or the
|
|
1086
|
+
`provider` itself — IS the app registration, so changing one (or switching a connection between the
|
|
1087
|
+
platform app and your own) is delete-and-recreate and `apply` says so instead of silently dropping the
|
|
1088
|
+
change. A *configuration* field — GitHub's `appSlug`, or `scope`/`redirectUri` — is freely re-configurable
|
|
1089
|
+
and rides an ordinary PATCH, as does rotating a secret reference. GitHub's App page slug is the motivating
|
|
1090
|
+
case: GitHub re-derives it when you rename the App, without issuing a new `appId`.
|
|
1091
|
+
- **Removing an optional configuration value is not appliable.** The platform's connector update is
|
|
1092
|
+
*set-only*: an omitted field means "leave unchanged", and there is no way to clear a stored one. So deleting
|
|
1093
|
+
the prop from your declaration neither clears it nor is silently ignored — `apply` prints a `note:` naming
|
|
1094
|
+
the field and leaves the live value alone. Declare the intended value to change it, or delete and re-declare
|
|
1095
|
+
the registration to drop it.
|
|
1096
|
+
|
|
1097
|
+
### Per-user OAuth grants (`orchestra user-grants grant`)
|
|
1098
|
+
|
|
1099
|
+
Some providers can act **as the individual user** rather than as the org install. That credential is the
|
|
1100
|
+
user's own, proved by a browser consent dance, so it is never a declared resource — but the dance is
|
|
1101
|
+
API-first, so you can drive one for testing:
|
|
1102
|
+
|
|
1103
|
+
```bash
|
|
1104
|
+
orchestra user-grants grant --connection workspace-slack --open
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
The command mints the grant intent under **your** token, prints (and with `--open` launches) the consent URL,
|
|
1108
|
+
and polls until the grant activates — you finish it in your own signed-in browser session, which is exactly
|
|
1109
|
+
what the platform's authenticated callback requires. `--timeout 0` mints and prints without waiting. Grants
|
|
1110
|
+
carry lifecycle metadata only; no surface ever reads the credential back.
|
|
1111
|
+
|
|
1112
|
+
### Chat presence (Slack and Microsoft Teams)
|
|
1113
|
+
|
|
1114
|
+
There is **no chat construct** — the `Channel` construct was removed, because the orchestra convention is
|
|
1115
|
+
construct ⇄ resource and no chat resource exists any more. A chat presence is exactly the connection triad above —
|
|
1116
|
+
**Connection**, **Inbound**, **Outbound** — with **approval delivery** (`approvalPolicy` on the `Workflow`)
|
|
1117
|
+
as the one extra owner. Nothing is bundled; there is no remainder.
|
|
1118
|
+
|
|
1119
|
+
#### 1. Connection — the installation's identity and voice
|
|
1120
|
+
|
|
1121
|
+
`autoRespond` is the ack reaction / typing status / failure notice the provider speaks per delivery. It lives
|
|
1122
|
+
on the connection because that hook fires once per delivery while several triggers can match one: the bot
|
|
1123
|
+
presence acks once, per its installation's config. Its type is generated from the provider's own catalog
|
|
1124
|
+
declaration, so `ack` does not typecheck on a Teams connection (Bot Framework gives a bot no reaction to add):
|
|
1125
|
+
|
|
1126
|
+
```ts
|
|
1127
|
+
import { Auth, Connection, Providers, Targets, Trigger } from "@orchestraworks/cli";
|
|
1128
|
+
|
|
1129
|
+
const slack = new Connection(factory, "slack", {
|
|
1130
|
+
provider: Providers.Slack,
|
|
1131
|
+
auth: Auth.oauth(), // created NEEDS_ATTENTION; apply prints the authorize handoff line
|
|
1132
|
+
autoRespond: { ack: { reaction: "eyes" }, typing: true, failureNotice: true },
|
|
1133
|
+
});
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
#### 2. Inbound — what the agent hears
|
|
1137
|
+
|
|
1138
|
+
Each posture the retired `respondTo` flags bundled is now one trigger. The
|
|
1139
|
+
`Providers.<P>.conversation` helpers are generated from the same catalog block, so hand-authored wire and
|
|
1140
|
+
orchestra read one source: event handles
|
|
1141
|
+
(Slack `.mention`/`.dm`/`.groupChat`/`.threadReply`; Teams one `.message` wire split by `.inChannel()` /
|
|
1142
|
+
`.inDirectMessage()` / `.inGroupChat()`), the binding templates that set session granularity
|
|
1143
|
+
(`.sessionPerThread()` / `.sessionPerConversation()`) and `.rooms([...])`. There is no echo exclusion: the
|
|
1144
|
+
bot's own messages reach the agent, which ignores them. `{{ event | xmlify }}` places the encoded `Event` in the prompt — the whole
|
|
1145
|
+
layout is yours, and the platform parses and reproduces nothing of the message itself:
|
|
1146
|
+
|
|
1147
|
+
```ts
|
|
1148
|
+
const conversation = Providers.Slack.conversation;
|
|
1149
|
+
const PROMPT = `A Slack message arrived. Help the user and reply with the Slack CLI.\n\n{{ event | xmlify }}`;
|
|
1150
|
+
|
|
1151
|
+
new Trigger(factory, "slack-mentions", {
|
|
1152
|
+
on: { connection: slack, events: [conversation.mention] },
|
|
1153
|
+
target: Targets.agent(responder, {
|
|
1154
|
+
binding: conversation.sessionPerThread(),
|
|
1155
|
+
prompt: PROMPT,
|
|
1156
|
+
attributeConnection: true, // the reply path's egress resolves against this installation
|
|
1157
|
+
}),
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
new Trigger(factory, "slack-thread-replies", {
|
|
1161
|
+
on: { connection: slack, events: [conversation.threadReply] },
|
|
1162
|
+
target: Targets.agent(responder, {
|
|
1163
|
+
binding: conversation.sessionPerThread(),
|
|
1164
|
+
prompt: PROMPT,
|
|
1165
|
+
attributeConnection: true,
|
|
1166
|
+
requireEverParticipated: true, // the old ThreadReplies.Participating: only threads already joined
|
|
1167
|
+
}),
|
|
1168
|
+
});
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
#### 3. Outbound — what the agent may say
|
|
1172
|
+
|
|
1173
|
+
The reply is an ordinary outbound request from the machine, so it needs an egress grant like any other. One
|
|
1174
|
+
line on the **receiving agent's environment** synthesizes the provider's `apiHosts` rule plus the
|
|
1175
|
+
`Authorization` binding the broker hydrates from that installation:
|
|
1176
|
+
|
|
1177
|
+
```ts
|
|
1178
|
+
const responderEnv = new Environment(factory, "responder-env", {
|
|
1179
|
+
/* … */
|
|
1180
|
+
egress: EgressPolicy.denyAll().allow("anthropic", { hosts: ["api.anthropic.com"], /* … */ }).allow(slack),
|
|
1181
|
+
});
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
Forget it and nothing looks wrong: synth, apply and routing all succeed, the agent runs — and then its reply
|
|
1185
|
+
CLI gets a 403 from the egress fence inside its own machine, where nothing surfaces it but decision logs. So
|
|
1186
|
+
synth notices that exact shape (a connection wired inbound to an agent whose environment has no rule bound to
|
|
1187
|
+
it) and prints:
|
|
1188
|
+
|
|
1189
|
+
```
|
|
1190
|
+
warning: connection 'slack' is wired inbound to agent 'codex-agent' but its environment 'codex-env' has no outbound rule for it — add .allow(slack) to its egress, or ignore this if that is deliberate (listener-only, or the hosts are covered by a hand-written rule).
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
It never blocks, and **the deliberate listener simply ignores it**: an agent that only reads a provider's
|
|
1194
|
+
events and answers somewhere else (or not at all) needs no outbound grant, and so does an agent whose access
|
|
1195
|
+
you authored as a hand-written named rule with its own credential — detection is by connection-bound rule
|
|
1196
|
+
only, and that false positive is accepted rather than papered over with host matching (a covering rule with
|
|
1197
|
+
the wrong credential still 403s). Anything synth cannot prove stays silent instead: an imported agent or
|
|
1198
|
+
environment is not in the plan, and a `Connection.fromEnv`/`fromId` binding resolves only at apply.
|
|
1199
|
+
|
|
1200
|
+
#### Asking a person to approve
|
|
1201
|
+
|
|
1202
|
+
An approval is a check a person sets on the work item, and a stage's `requires` can wait on it. A rule posts
|
|
1203
|
+
the request to a chat channel through the connection, which apply resolves to its id and checks is usable:
|
|
1204
|
+
|
|
1205
|
+
```ts
|
|
1206
|
+
new Workflow(factory, "release-flow", {
|
|
1207
|
+
pipeline: releases,
|
|
1208
|
+
rules: [
|
|
1209
|
+
rule({
|
|
1210
|
+
name: "Ask the release channel to sign off",
|
|
1211
|
+
when: entered("sign-off"),
|
|
1212
|
+
then: [message(toChannel(slack, "C0RELEASES"), "{{workItem.title}} is ready to ship. Approve it on the board.")],
|
|
1213
|
+
}),
|
|
1214
|
+
rule({ name: "Ship a release once a person approves it", when: check("approval").is("PASS"), then: [move("shipped")] }),
|
|
1215
|
+
],
|
|
1216
|
+
});
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
The agent replies through its provider CLI (egress injects the Connection credential and fences the target
|
|
1220
|
+
conversation); the Session transcript never carries provider credentials or Teams transport addresses.
|
|
1221
|
+
The three statements above are the whole lane for either provider — Teams differs only in which
|
|
1222
|
+
`conversation` handles typecheck (no reaction to add, one `.message` wire split by scope).
|
|
1223
|
+
|
|
1224
|
+
## 8. Running several instances
|
|
1225
|
+
|
|
1226
|
+
Parameterize the factory name to get a fresh slug namespace per deployment instance (the pattern the
|
|
1227
|
+
support-bugfix example uses):
|
|
1228
|
+
|
|
1229
|
+
```ts
|
|
1230
|
+
const instance = process.env.ORCHESTRA_FDK_INSTANCE?.trim();
|
|
1231
|
+
const factoryName = instance ? `acme-support-${instance}` : "acme-support";
|
|
1232
|
+
const factory = new Factory({ name: factoryName });
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
Each distinct factory name globally reserves its slug namespace on first apply. Watch the **slug budget**:
|
|
1236
|
+
every derived slug (factory name + construct path) must stay ≤ 60 chars, so keep `ORCHESTRA_FDK_INSTANCE` short
|
|
1237
|
+
(≤ 16 chars is safe); synth enforces the bound.
|
|
1238
|
+
|
|
1239
|
+
## 9. Troubleshooting
|
|
1240
|
+
|
|
1241
|
+
| Symptom | Cause & fix |
|
|
1242
|
+
| --- | --- |
|
|
1243
|
+
| **409 on create** | The globally-unique slug is claimed. Three indistinguishable causes: (a) a concurrent apply already created it — re-run apply; (b) another org owns the slug — pick a new explicit slug or rename the factory; (c) a soft-deleted same-org tombstone still holds it (no slug reclaim) — restore out-of-band or accept a new slug. |
|
|
1244
|
+
| **401 / 403** | The token lacks access to the resolved org, or it expired and could not be renewed. A profile from a browser `login` renews itself silently, so a 401 that survives that means the renewal credential is gone too — re-run `orchestra auth login`. A pasted or exported token has no renewal path at all: replace it, or fix `ORCHESTRA_ORGANIZATION_ID`. |
|
|
1245
|
+
| **"the platform rejected the stored refresh credential… Run `orchestra auth login` to sign in again."** | The stored renewal credential was refused (revoked, expired, or a sibling process already rotated past it). Nothing on disk is changed by that failure, so re-running `orchestra auth login` is the whole remedy. A *retryable* failure — the platform unreachable, timing out, or answering `temporarily_unavailable` — reports itself as such and never tells you to log in again; run the command again. |
|
|
1246
|
+
| **`ERR_PACKAGE_PATH_NOT_EXPORTED` at first import** | The factory program's own `package.json` is missing `"type": "module"` (a fresh `npm init -y` defaults to CommonJS). orchestra is ESM-only — its `exports` map defines only an `import` condition — so add `"type": "module"` to the factory `package.json` (see §4). |
|
|
1247
|
+
| **Import 404** | The referenced slug/id isn't visible in the resolved org. Check the org and the reference. |
|
|
1248
|
+
| **"refusing to send secrets over plain HTTP to non-loopback host …"** | A secret write needs TLS. Use an `https://` base URL; only loopback (`127.0.0.1`/`localhost`/`::1`) dev stacks may use `http://`. |
|
|
1249
|
+
| **`tsc` error on `target: Targets.…({ binding })`** | The binding is scoped to events the trigger does not fully select (its field is absent from one payload shape). Split into per-shape triggers sharing one binding constant and one anchor (the composition pattern), or author a binding valid for the whole selection. |
|
|
1250
|
+
| **Synth note: "the selected events share no canonical binding"** | The target declared no `binding` and the selection's events carry different (or no) canonical keys, so the target is keyless — one-shot sessions, or one work item per event. Declare a binding explicitly if a keyed holder was intended. |
|
|
1251
|
+
| **Synth warning: "connection '…' is wired inbound to agent '…' but its environment '…' has no outbound rule for it"** | The connection triad is missing its third statement: add `.allow(connection)` to that environment's egress. Ignorable when deliberate — a listener-only agent, or hosts already covered by a hand-written named rule. |
|
|
1252
|
+
| **Synth error: "selects on a connection import that carries no slug"** | `on.connection` rides the wire as the connection SLUG, which the platform matches verbatim. `Connection.fromId`/`fromEnv` resolve an id, so pass a managed `Connection`, `Connection.fromSlug(...)`, or the slug string. |
|
|
1253
|
+
| **Synth error: "connection '…' selects '…' in its install '…', which provider '…' does not admit"** | A per-connection event selection (an install field whose values are the provider's own event tokens, e.g. Azure DevOps' `events`) names a token outside the provider's admitted vocabulary — usually a typo. The message lists the admitted set; fix the value, or omit the field entirely (an absent or empty selection means all admitted types). The platform's install write seam rejects the same value at apply, so synth just says it sooner. |
|
|
1254
|
+
| **Synth warning: "trigger '…' selects event '…' on connection '…', whose install '…' selection excludes it"** | That trigger can never fire: the connection's selection narrows what the provider is subscribed to, and this wire type is not in it. Add the named token to the connection's selection, or ignore it — narrowing now and widening later is legitimate authoring order, and a trigger staged ahead of its selection is a normal intermediate state. |
|
|
1255
|
+
| **Synth error: "egress rule '…': this policy already declares a rule with that name"** | Every rule on one `EgressPolicy` needs its own name (§4). Usual causes: a rule you hand-authored collides with a preset row (`software-development-npm`), the same bundle is applied twice, or a connection's bundle is applied beside `allow(connection)` — which it supersedes. Rename your rule, or drop the duplicate call; the message names both the rule and the knob to turn. |
|
|
1256
|
+
| **`allow(connection)`: missing install param** | A provider `apiHost` carries a `{param}` (e.g. `{subdomain}.zendesk.com`) with no matching `install` value on the connection. Add the install field the error names. |
|
|
1257
|
+
| **Apply error naming a secret and an env var that "is not set"** | `SecretValue.fromEnv` is strict (§6): unset, empty and whitespace-only are all failures, on a terminal and in CI alike. Export the variable, or change the declaration to `SecretValue.fromCommandLine()` if a human is meant to type the value. There is no interactive fallback — older orchestra versions had one, and it is gone. |
|
|
1258
|
+
| **Apply error: the resource "declares an interactive credential"** | A `SecretValue.fromCommandLine` credential reached an apply with no terminal to type into (CI, a piped run, a cron job). Either run it interactively, or switch that declaration to `SecretValue.fromEnv("NAME")` and supply the value through the environment. |
|
|
1259
|
+
|
|
1260
|
+
## 10. Learn more
|
|
1261
|
+
|
|
1262
|
+
Everything orchestra knows ships inside the installed package — this guide is the reference, and:
|
|
1263
|
+
|
|
1264
|
+
- [`skills/orchestra/SKILL.md`](../skills/orchestra/SKILL.md) — the reference skill: the platform's ideas,
|
|
1265
|
+
how the CLI works, what the platform cannot do yet, and where an agent's knowledge belongs.
|
|
1266
|
+
- [`skills/orchestra-fdk/SKILL.md`](../skills/orchestra-fdk/SKILL.md) — the setup skill: building and applying
|
|
1267
|
+
a factory for an existing codebase, with environment porting in `references/environment.md` and the
|
|
1268
|
+
authoring reference — the construct model, the two-file factory shape, the synth → diff → apply loop,
|
|
1269
|
+
secrets-as-recipes and the six design principles — in `references/authoring.md`. `orchestra skills install`
|
|
1270
|
+
(§2) links both into your own coding harness, so the agent writing your factory reads them too.
|
|
1271
|
+
- `orchestra --help` lists every command, and `orchestra <command> --help` gives its flags — the authoritative
|
|
1272
|
+
list is the CLI you just installed, never a doc that can drift from it.
|
|
1273
|
+
|
|
1274
|
+
## 11. License
|
|
1275
|
+
|
|
1276
|
+
orchestra is released under the [Apache License 2.0](../LICENSE) — the full text ships in this package.
|
|
1277
|
+
|
|
1278
|
+
Copyright 2026 Living Computers
|