Secure multi-agent task management desktop tool. Run multiple agents in parallel from one UI, review their deliverables from sandbox → host, and keep context/token usage optimized.
Built as a Tauri + React + TypeScript desktop app. The UI is a faithful implementation of
the Orchestra.dc.html design (imported into design/ for reference).
The whole design follows from one assumption: an agent is untrusted code. So agents run in a
private network that has no route to the host and no route out, and the only thing they can reach
is the gateway — which holds the API keys in its own memory, decides per request whether the
destination is allowed, and records what went through. The red crosses above are the paths that
are closed by construction rather than by policy: sandbox → host, sandbox → the open internet,
gateway → the host's own data. See docs/ARCHITECTURE.md for the
layer-by-layer rationale.
- Delivery — per-repo agent deliverables on an
inbox(起票) → working(判断待ち) → done(完了)board. Review drawer with pipeline selector, CI gate (self-review unlocks only after CI passes),差分 / 原本(編集可) / エビデンスtabs (diff, editable worktree source, VRT/API evidence), and an A2A log footer. - Daily — scheduled tasks with a gallery/calendar view; artifacts across video/image/text/voice.
Plus pull-model task ingest from external systems of record (Jira / Trello / Notion): pull
assigned tickets on demand (through the gateway, so no keys touch the app), and 起票 a ticket
into a Delivery worktree (branch
ticket/<id>) to work it through the normal git flow. - Terminal — embedded multi-pane terminals to converse with agents per worktree.
- Audit — hierarchical A2A logs (Context/Task/Artifact/Message/Part/Extensions/Metadata) with granularity/type/time filters, plus a metrics view (tokens, sessions).
- Settings — Prompt & history compression; RAG — index explicitly-added local knowledge
folders (read-only mounted into a dedicated container) and search them, agents retrieving only via
the gateway's
/ragroute (no sandbox→host access). Agent templates (Solo / Static Multi-Agent supervisor+graph / Dynamic Orchestration) — persisted (localStorage), editable, and executable: Graph runs as a Stage DAG, Supervisor compiles toplan→workers→integrate, Dynamic routes to a template at run time. A Solo picks a provider + model, opt-in custom HTTP tools and RAG. Tools — define gateway-routed HTTP tools (params →{{substituted}}). Proxy / providers — add/edit LLM providers (Anthropic / OpenAI / Gemini and custom): endpoint, models, and API keys (write-only, OS keychain, injected by the gateway — never in localStorage / the container / a sandbox). Task sources — add/remove the Daily pull providers (Jira / Trello / Notion); persisted host-side and hot-reloaded, adapters route through the gateway (which injects each provider's credentials). Sandbox isolation + forbidden-command policy. - Workspace — VSCode-like worktree editor (file tree, syntax highlight, references) opened from a Delivery review.
Dark and light themes (toggle in the top nav). All visuals are driven by CSS variables in
src/styles/index.css — the single reskin surface.
pnpm install
pnpm dev # Vite dev server at http://localhost:1420
pnpm build # tsc + production build./scripts/dev.sh # dev: builds sidecars, runs the native window
# native arm64 installer (.app + .dmg), sidecars bundled via externalBin:
rustup target add aarch64-apple-darwin
pnpm tauri build --target aarch64-apple-darwinThe x86_64-apple-darwin toolchain also works (runs under Rosetta); pass that target instead for
an Intel build.
- State: a single Zustand store (
src/store/useStore.ts) — theme, notifications, delivery board, review drawer. - Routing: hash routes map 1:1 to the top-nav tabs (+
/workspace). - Screens live under
src/features/<screen>/; the app frame issrc/components/layout/. - Security model — how untrusted agents are isolated from the host and internet
(L3
--internalegress island, the dual-homed gateway, L7 allowlist/SSRF-deny/key injection, the file-based delegation channel), and how the same chokepoint doubles as a complete, tamper-evident audit plane, is documented indocs/ARCHITECTURE.md.
Spawned by the Tauri shell and torn down on exit (see
src-tauri/src/sidecars.rs):
gateway/:8787— security-proxy. Single egress for sandboxed agents: session auth, upstream allowlist, API-key injection, rate limit, token/cost budget, body-size limit, timeout, SSE streaming, structured access logs. Agents never hold keys. Access records are captured at this single chokepoint and, whenORCHESTRA_AUDIT_DIRis set (a host volume in container mode), persisted to an append-only, hash-chained SQLite log that survives restarts —GET /_gateway/audit/verifyrecomputes the chain and reports the first tampered row, so the structural tamper-evidence becomes cryptographic. (Without it, the in-memory 500-record ring is used.) It also enforces write authorization per upstream: thegithubservice is deny-by-default for mutations — only branch-create / PR-create / comments pass, while PR merge, pushes to protected branches (main/master/develop), and directcontentscommits are rejected (opt back in viawriteAllowin the config). So even with aGITHUB_TOKEN, an agent cannot land changes onto a base branch. Runs as a Docker container — it is the only component that holds keys and reaches the internet, and it sits on the internal sandbox network so sandboxes can reach only it. Providers are dynamic: a loopback admin API (/_gateway/providers,…/secret) adds/edits providers and injects secrets into gateway memory. Two guardrails make this safe: the admin API needs an admin token the gateway holds only as a SHA-256 hash (the raw token lives host-side, in the Tauri shell) and which sandboxes never receive; and an SSRF deny refuses to forward to loopback / RFC1918 /host.docker.internaltargets. Secrets are never returned by any endpoint (GETexposes onlyhasSecret).hostagent/:8788— git worktrees for agent deliverables → real structured diffs against the target branch, CI gate, and merge on self-review approval (/task/mergerequires CI passed). The Delivery board's live mode reads this. Runs as a loopback host process — not reachable from a strict sandbox (merge stays a host action). State (schedules, pulled Daily tickets, configured task sources) is persisted in an embedded SQLite database under the app data dir (ORCHESTRA_DATA_DIR; pure-Go driver, migrations) so it survives restarts. It also hosts the Daily pull endpoints (/daily/pull,/daily/tickets,/daily/promote,/daily/sources).sandbox/:8789— Docker sandbox controller. Also hosts the multi-agent orchestrator (/run): a template-agnostic executor for a Stage DAG — each stage is one hardened sandbox; a stage runs once itsdependsOnstages succeed, up tomaxParallelat a time, handing work off through the shared worktree. A Graph template (Settings) is compiled client-side into such a DAG and run from a live task's review drawer. Runs each agent in a hardened container: only its worktree mounted,--read-onlyroot,--cap-drop ALL,--security-opt no-new-privileges, no host env. In strict isolation (the default) the container joins the--internalorchestra-egressnetwork: no route to the host or the internet — the gateway and the registry proxy are the only things it can reach. Arelaxedmode (ordinary bridge, for future interactive terminals) is available per request; the controller derives the gateway and registry base URLs from the mode server-side, so a client cannot downgrade egress or redirect where dependencies come from. It also owns the container-image allowlist (/images): a stage names a policy (base/poly/media, or a custom one added in Settings), never an image reference — the controller supplies the ref, the network posture, the resource caps and the writable scratch paths, so the hardening flags are identical for every image. The reference is resolved host-side to an immutable digest before launch and recorded on the stage, and images must be explicitly promoted before an unattended (Daily) run may use one.registry/:8791— package-registry proxy (container), the dependency-fetch counterpart to the gateway. Dual-homed on egress + upstream, sonpm install/pip install/go mod downloadwork inside a sandbox that has no route to the internet. It serves GET/HEAD only (an agent can fetch packages but can never publish one), to fixed upstreams with bounded redirects and no credentials, rewrites the absolute download URLs in registry metadata back to itself, logs every fetch, and holds the durable artifact cache so disposable sandboxes don't re-download their dependency tree. Module checksum verification stays on.ragindex/:8790— RAG indexer (container). Indexes a read-only knowledge mount into an in-memory embedding store (embeddings fetched through the gateway — it holds no key) and answers similarity search. It sits on theorchestra-upstreamnetwork (gateway-reachable) but not on the sandbox egress network, and publishes only a loopback management port — so a sandboxed agent reaches search only via the gateway's/ragroute, never this service or the host directly. The knowledge lives only here. Beyond text/code it ingests CSV/TSV (rendered a row per line, each cell labelled with its column header), PDF (text layer, page by page, pure Go — a scan with no text layer falls back to metadata and says so) and subtitle tracks (.vtt/.srt, cues stripped to speech). Images and video are registered but not read: their path and filename are embedded so the file is findable, and the Source is markedcontent: metadataso the UI can say パスのみ rather than imply the contents are searchable. A video's spoken content becomes searchable by putting a caption track next to it (demo.mp4+demo.ja.vtt) — which is why no media parser (ffmpeg/libvips) runs inside the one container that holds all the knowledge. Which folders (and external HTTPS documents) it may read is registered in Settings → RAG; each local folder is bind-mounted read-only into this container and nowhere else. A bind mount cannot be added to a running container, so adding or removing one restarts the indexer and rebuilds the index — it keeps nothing durable, so that costs a re-index and nothing else. (ORCHESTRA_KNOWLEDGE_DIRstill seeds the list for installs that predate the UI.)
# each service: go test ./... && go run . -config config.jsonAgent runtime (agent/)
The program that runs inside each sandbox (image orchestra/agent:latest). Working dir /work
is the mounted worktree. It runs a tool-use loop (read_file / write_file / edit_file
/ list_files, all scoped to /work) through the gateway — so it holds no API key (it sets only
Content-Type; the gateway injects credentials). The loop is written against a neutral model and a
Provider interface speaks three dialects — Anthropic Messages / OpenAI Chat Completions /
Gemini generateContent — selected per run by ORCHESTRA_PROVIDER (+ ORCHESTRA_BASE_URL /
ORCHESTRA_MODEL), so a Solo agent's chosen provider+model drives which model runs. It authenticates
to the gateway with ORCHESTRA_SESSION (scrubbed before the upstream call). Beyond the file tools it
can be given custom HTTP tools and a rag_search tool (via ORCHESTRA_TOOLS) — both are calls
through the gateway, so the allowlist / SSRF-deny / write-authz / key-injection apply and the agent
still holds no keys.
It can also be granted media generation (via ORCHESTRA_MEDIA): generate_image,
generate_speech and generate_video write an image, a spoken track or a video into /work, where
Delivery's 成果物 tab and Daily's gallery both pick it up. Until this existed the file tools wrote
strings, so a task could only ever produce text. Each kind is a separate grant on the agent
template — they differ by an order of magnitude in cost, and video especially has to be turned on
deliberately. An unconfigured kind is an absent tool, not a disabled one. Generation is a model
call like any other: routed through the gateway, keys injected there, recorded in the audit log with
its run and stage, and stripped entirely from a networkless (media-policy) stage that could not
reach an upstream anyway. The written path must carry an extension matching its kind — a generated
file that lands as .sh is not an artifact.
It can also be granted web search (via ORCHESTRA_WEB_SEARCH), and this one is unlike every
other tool: the agent does not run it. web_search is an Anthropic server tool — the agent
advertises it in the same tools array, Anthropic performs the search on its own infrastructure, and
the query and its results arrive as extra content blocks in the same /v1/messages response. So the
container never opens a socket to the web, the egress island is unchanged, and the gateway sees the
model call it already sees — no new upstream, no new key, no new allowlist entry. That is why it is
preferred here over wiring a search API up as a custom HTTP tool. It is still a per-agent grant
(searches are billed per use, and an agent that was not given the tool answers from what it knows
rather than appearing to have looked) with a use cap that defaults to 5. A searching turn can
stop with pause_turn — unfinished, not over — which the loop resumes by echoing the turn back
verbatim. It only exists in the Anthropic dialect: the OpenAI and Gemini encoders drop server tools,
and the grant is not compiled onto stages using those providers at all. It emits A2A-style JSON logs. go test ./... && docker build -t orchestra/agent:latest .
Three sandbox images ship, all built from agent/ and selected per stage by policy name:
| Policy | Dockerfile | Contents | Network |
|---|---|---|---|
base |
Dockerfile |
distroless, agent binary only — no shell, no toolchain | egress |
poly |
Dockerfile.poly |
Node 22 / Python 3 / Go 1.25 + shell, for build/test/debug command stages (stage.cmd) |
egress |
media |
Dockerfile.media |
ffmpeg / ImageMagick / libvips for Daily's video·image artifacts | none |
media is deliberately not part of the common image: media parsers consume untrusted binary
input, so keeping them out of every sandbox avoids widening every agent's attack surface — and as
its own stage it needs no network at all, which makes it more confined than a normal sandbox, not
less. Toolchain caches are tmpfs (gone with the container); the durable cache lives in the
registry proxy, where a sandbox can only read it.
Verified end-to-end: the sandbox launched the agent container against a mock Messages API; the agent
ran its loop (tool_use write_file → end_turn) and wrote a file into the worktree, with
ReadonlyRootfs=true, CapDrop=[ALL], and only the worktree mounted rw.
Sidecars are reaped on app quit (RunEvent::Exit) and self-terminate if the app is SIGKILLed
(a parent-PID watchdog in each service). They're bundled into the shippable app via Tauri
externalBin (pnpm tauri build).
# Optional bring-your-own seed: on launch the app pushes these into the gateway (via the
# loopback admin API) as the anthropic/github secrets if the keychain has none. You can also
# just enter keys in Settings → Proxy (stored in the OS keychain). Keys never reach agents.
export ANTHROPIC_API_KEY=... GITHUB_TOKEN=... # optional
./scripts/dev.sh # builds the sidecars + gateway container image, wires them, runs `tauri dev`The frontend's Delivery board runs on mock data by default; click mock data → live
in the board header to connect to the host agent (configs/hostagent.json lists your repos)
and see real worktree tasks, diffs, CI, and merges. On a live task, the review drawer's
エージェント実行 button launches the agent runtime in a Docker sandbox against that worktree
(via the sandbox controller) and streams its A2A logs — the full loop, from the UI.
Delivery, Daily and Audit ship with mock data, so they have something on screen from the first
launch. Knowledge does not — it shows what the indexer actually holds, which is nothing until
a folder is registered. examples/knowledge/ is a small fictional corpus
(a payments platform and an inventory service) that exercises every ingestion path: Markdown,
CSV/TSV, a PDF with a real text layer, SVG/PNG images, and a video with a caption track next to it.
./scripts/seed-demo.py # registers the corpus, then seeds the graph + assigns sourcesIt runs in two halves because they need different things to be true. Registering the folder only writes a file, so it works with the app closed — and takes effect on the next launch, since a bind mount cannot be added to a running container. Seeding the graph (organizations → projects → groups → relations) goes through the same HTTP the UI uses, so it needs the app up and the index built. Run it, restart the app, run it again. Both halves are idempotent, and everything it creates can be edited or deleted on screen afterwards — none of it is a fixture the app knows about.
The Knowledge trace — which nodes a run actually reached — is built from what the gateway
recorded, so it does not exist until some run has retrieved something. Nothing can manufacture it:
a trace is evidence, and evidence has to be earned by a real retrieval. --trace fires one:
./scripts/seed-demo.py --trace # one RAG-using agent run (a real model call, so it costs)It asks three questions rather than one, because a single query lights a single spot on the graph
and the screen is worth looking at only when some nodes are lit and most are not. When it finishes
it prints the run id; open it from Audit's トレース button, or go to Knowledge with ?run=<id>.
Without an embeddings provider the index cannot build at all, which leaves the one screen that explains the whole idea unreachable. For that case:
ORCHESTRA_EMBED_MODE=offline ./scripts/dev.shVectors are then computed locally from hashed token counts instead of by a model. Documents that
share vocabulary land near each other, so the graph has real structure and a search returns
something defensible — but it cannot see that 冪等 and idempotent mean the same thing, and never
will. It is opt-in from the environment and never a fallback, /status reports embedMode, and
Settings → RAG says so on screen: an index that quietly stopped using the model would be worse
than one that stayed empty and explained why.
The diagram at the top shows the trust boundaries; this is the same shape with the concrete network names and ports:
┌─ Tauri (Rust) window ─────────────────────────────────────┐
│ React UI (6 screens) │
│ spawns: hostagent :8788 (host) sandbox :8789 (host) │
│ gateway (container) ── published 127.0.0.1:8787 │
└──────────────────────────┬────────────────────────────────┘
│ provisions docker networks
┌────────────────────┴───────────────────────┐
│ orchestra-egress (--internal: no host/net) │
│ ┌───────────┐ ┌──────────────────┐ │ orchestra-upstream
│ │ sandbox │──────▶ │ gateway │ │──────▶ api.anthropic.com
│ │ (agent) │ only │ (keys + authz) │ │ api.github.com
│ │ │ └──────────────────┘ │ (keys injected)
│ │ │ ┌──────────────────┐ │
│ │ │──────▶ │ registry proxy │ │──────▶ registry.npmjs.org
│ └───────────┘ only │ (GET/HEAD only) │ │ pypi.org · proxy.golang.org
│ └──────────────────┘ │
└─────────────────────────────────────────────┘
A strict sandbox can reach ONLY those two proxies — not the host, not the internet.
For the full layer-by-layer rationale — why this is enforced by L3 routing rather
than a firewall allowlist, how the gateway is dual-homed, and how runtime delegation
stays inside the boundary — see docs/ARCHITECTURE.md.
