Orchia hands your request to AI agents (a developer, a
reviewer, a DevOps) that work on a branch of your GitHub
repository. Nothing is merged unless CI is green.
Sign in with GitHub. You bring your own Anthropic, OpenAI or
OpenRouter key, so model usage is billed by your provider,
not by Orchia. Early access.
The Orchia home screen. Pick a repository, say what you
want done.
How it works
Say what you want
Choose a repository and write the request the way
you would to a colleague: fix the vulnerable
dependencies, review this pull request, audit the
auth code.
Agents split the work
A model drafts a plan. The developer writes the
change on a branch, the reviewer reads the pull
request diff, the DevOps checks CI. Steps that do
not depend on each other run in parallel, and you
watch each one live.
Developer→Reviewer→DevOps
Merge only when it is green
The result is a pull request on your repository.
The DevOps agent can merge it, but the merge call
refuses unless CI passes and GitHub says the pull
request is mergeable.
Built to be trusted with your repository
No shell
Agents get file tools, GitHub tools and MCP tools,
nothing else. Code execution on the server is
switched off in production.
Branches, not main
A write aimed at a protected branch is redirected
to a new fix/orchia-* branch and a pull
request.
CI decides the merge
Failed, pending or unmergeable: the merge is
refused, whatever the agent asks for.
Your keys, your provider
API keys are encrypted at rest. Prompts and the
files agents read go to the model provider you
chose. Where data goes.
Orchia is early, and it is not a sandbox. The
list of known limits is on this page
on purpose.
Under the hood
The rest of this page is the engineering case study:
architecture, three design problems, and the known limits.
It is written for engineers who want to see how Orchia
works and where it breaks. Below is the stream of events a
plan run pushes to the browser.
The events a plan run pushes to the browser over
WebSocket. Event names, statuses and tool names are the
real ones; the step text is paraphrased and the
repository, file and pull request values are
placeholders.
What it is and what it is not
It is
A hosted, multi-user service: Express,
PostgreSQL and a React frontend, with GitHub
OAuth.
An agent runner where each role gets a fixed
tool set: file tools, GitHub tools, MCP
servers. Agents have no shell.
A plan executor: a model drafts a dependency
graph of agents, and the server runs the
independent branches in parallel.
It is not
A sandbox. There is no operating-system
isolation. Server-side code execution is
switched off in production instead.
Horizontally scalable. The job worker, plan events and WebSocket routing live in one process.
Local or private. Prompts and the file
contents agents read go to the model provider
the user chose, and all state is in
PostgreSQL. See
where data goes.
Architecture
One Node process serves the API, the WebSocket endpoint and
the built frontend. It is managed by PM2 on a 2 vCPU, 4 GB
Hetzner machine behind Caddy. PostgreSQL is managed by
Supabase and used as a plain database; authentication is
implemented in the app (GitHub OAuth, short-lived JWT in an
httpOnly cookie, rotating refresh tokens).
Dashed boxes are trust boundaries. The user's OAuth
token for GitHub and the user's own model API keys are
stored encrypted in PostgreSQL and decrypted in the
server process only when a call is made.
One command, from prompt to UI
POST /api/commands/:agentId, the path every chat message takes. The request records the command and queues a job; a worker runs it. For each step: whether it blocks, what happens on failure, and where the state lives.
Authenticate
requireAuth verifies the access-token
cookie (JWT, 15 minutes) and sets
req.userId.
Blocking
Yes
On failure
401
State
None, stateless
Authorize
The agent is loaded with
WHERE id = $1 AND user_id = $2.
Another tenant's agent id returns 404, not 403.
Blocking
Yes
On failure
404
State
PostgreSQL
Record and queue
A commands row is inserted with
status running and a job is queued
in the jobs table. The response is
202 and the client polls for the result. A
repeated command id does not queue a second job.
Blocking
Yes
On failure
500, nothing runs. 429 when the user already has too many queued jobs
State
PostgreSQL
Check the daily budget
Tokens used today by finished commands are summed
and compared to the user's limit (500,000 by
default).
Blocking
Yes, on the worker
On failure
The command errors with the usage figures
State
PostgreSQL. The check is read then run, not atomic
Run the model loop
A worker claims the job and keeps a lease alive with a heartbeat. It calls the model, executes its tool calls and feeds the results back: at most 10 iterations, 20 for the reviewer and DevOps roles. No HTTP request is held open.
Blocking
Yes for the worker, for minutes if needed
On failure
A tool error goes back to the model as a result; an exception marks the command as error
State
The message list, in process memory. A command is not re-run after a crash
Gate every tool call
File paths are resolved and checked; in production
the server's disk is refused outright. GitHub
writes aimed at a protected branch are redirected
to a new fix/orchia-* branch.
Blocking
Yes
On failure
A refusal string returned to the model
State
None
Stream progress
Each step and log line is emitted on an
EventEmitter, wrapped as
{ type, data } and sent to that
user's sockets.
Blocking
No, fire and forget
On failure
A disconnected client misses the events; there is no replay
State
None
Persist
Output, duration, tokens and cost are written to
the row and a trace is sent to Langfuse when it is
configured. The client's next poll returns the
output.
Blocking
Yes
On failure
The row is marked error
State
PostgreSQL
Three problems that shaped the design
Parallel agents writing to one plan
Problem
A plan is a list of agents with
dependsOn. Each wave of ready
agents runs under Promise.all, and
each one reports its status into the same
JSONB array on the plans row.
Reading the array, changing one element in
JavaScript and writing it back loses updates
when two agents finish together.
Approach
Do the merge in a single SQL statement, so
PostgreSQL serialises the writes on the row.
The scheduler loop is bounded by the number of
agents plus two, so a plan whose dependencies
can never be met ends instead of spinning.
async function updatePlanAgent(planId: string, agentId: string, patch: Record<string, unknown>) {
await pool.query(
`UPDATE plans
SET agents = (
SELECT jsonb_agg(
CASE WHEN elem->>'id' = $1 THEN elem || $2::jsonb ELSE elem END
ORDER BY ord
)
FROM jsonb_array_elements(agents) WITH ORDINALITY AS arr(elem, ord)
), updated_at = NOW()
WHERE id = $3`,
[agentId, JSON.stringify(patch), planId],
);
agentManager.emit('orchestratorAgent', { planId, agentId, ...patch });
}
backend/src/routes/orchestrator.ts
Trade-off
One JSONB column keeps a plan to a single row
the UI reads in one query. The price is that
every status change rewrites the whole array,
and agents cannot be indexed or queried on
their own. Finished agents are read back from that same array, which is what lets an interrupted plan resume.
What breaks at scale
Plans now run as jobs on a PostgreSQL queue, but the worker still lives in the web process, so all plans share one event loop and one machine. The next steps are a plan_agents table with row-level updates, and workers that claim single agents instead of whole plans.
Letting a model change code without giving it a shell
Problem
Orchia began as a local tool where agents could
touch the machine. Hosted, the same features
mean any signed-in user can make the server do
things.
Approach
Agents have no shell tool at all. They get file
tools, GitHub tools and MCP tools. On GitHub
projects, changes go through the GitHub API to
a branch and a pull request; only the DevOps
role is given the merge tool, and the merge
call itself refuses unless CI is green. On the server's
own disk, in production every file tool is
refused; elsewhere paths are compared to the
project root after resolving symlinks.
What I got wrong first. The code had
a blocklist of dangerous commands, and I believed it
was the safety layer. In a review of my own code I
found it was never on the execution path. The real
exposure was elsewhere: an agent's type
and command came straight from the
request body, and three agent types spawned processes
that inherited the server's environment, including the
encryption key and the database URL. Any signed-in user
could have used that. In production those types are
now rejected when an agent is created or edited and
again when it runs, child processes get an environment
stripped of server secrets, and there are tests for
both. I deleted the blocklist.
Trade-off
Hosted agents cannot run tests or builds. The
DevOps role reads CI status from GitHub
instead, and the merge is left to that role.
What breaks, and what I would build
Path checks are string logic and leave a gap
between the check and the use. The real answer
is isolation the model cannot argue with: a
container or microVM per run, non-root, no
network by default, a workspace volume as the
only writable path, and CPU, memory and time
limits. That does not exist yet, which is why
execution on the server is off rather than
filtered.
Bounding delegation, and surviving a restart
Problem
An orchestrator that can call other agents can
loop, and each call costs tokens. Separately,
runs live in process memory and every deploy is
a restart, so work in flight was orphaned: its
row stayed running forever, and a
plan stuck in that state was refused on
re-run.
Approach
Delegation is one level deep: a delegated agent
is created without the orchestration tools. An
agent cannot call itself or another
orchestrator, and only Claude agents can be
delegated to. Every loop has an iteration
ceiling and every user a daily token budget. Runs are jobs in a PostgreSQL table. A worker holds a lease on its job and renews it with a heartbeat; when a worker dies, the next one to notice re-queues the job. A plan gets two attempts and resumes from the agents that already finished. A chat command is never re-run, because it may already have written to GitHub, so it is failed with an explicit message. On SIGINT or SIGTERM, running jobs release their lease, so a deploy hands them over within seconds.
UPDATE jobs
SET status = 'running',
attempts = attempts + 1,
locked_by = $1,
lease_expires_at = now() + ($2::int * interval '1 millisecond')
WHERE id = (
SELECT j.id FROM jobs j
WHERE j.status = 'queued' AND j.run_at <= now()
ORDER BY j.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *
backend/src/services/jobQueue.ts (claim query, without the per-user concurrency check)
Trade-off
Re-running an interrupted plan agent can repeat what it had already done on GitHub, a second branch or a second comment, because those writes are not idempotent. A chat command that was mid-run during a deploy is lost, and says so. The depth limit makes cycle detection
unnecessary, which is a limit, not a solution.
The budget check reads then runs, so two
concurrent commands can both pass, and
delegated calls skip it.
What breaks at scale
The queue is safe with several workers, that is what the lease is for, but progress events are not: they go through an in-process EventEmitter, so a worker in another process could not reach the WebSocket. That needs pub/sub, and LISTEN/NOTIFY is enough to start. The budget also has to become an atomic reserve-then-settle instead of a read.
If this had to serve 10,000 users
The first thing to break is the assumption of one process.
Agent ownership, bridges, caches and rate-limit counters
are all in memory, so a second instance would split them
and a restart would erase them. Commands and plans already run as jobs from a durable queue and the request returns at once, but progress is still pushed over a WebSocket that has no replay and no backpressure handling. The redesign, in order: move the workers to their own processes, which needs events to travel through pub/sub; move ownership and rate limits to Redis or PostgreSQL; give events sequence numbers so a client can resume; give each run an isolated workspace.
Model cost does not scale with the service, because users
bring their own API keys, but provider rate limits do.
Where data goes
Prompts and file contents go to the
model provider the user picked, with the user's own
API key, under that provider's terms.
Repository content is read and written
through the GitHub API with the user's OAuth token.
State is in PostgreSQL on Supabase:
accounts, agents, commands with their outputs, logs, plans, jobs and agent memories. Every row carries a
user_id and queries filter on it. There is
no row-level security, so isolation depends on every
query doing that.
Secrets (API keys, OAuth tokens) are
encrypted at rest with AES-256-GCM. The key is an
environment variable of the same server, with no KMS
and no rotation, and the server refuses to start in
production without it.
Observability. Errors go to Sentry and
full command inputs and outputs go to Langfuse when
those services are configured.
Bridge. With the bridge connected, file
tools run on the user's machine. What the model reads
still goes to the provider.
Types, data model and tests
TypeScript strict is on for the backend. Turning
it on produced 66 errors, 55 of them from one declaration:
req.userId was optional, although
requireAuth guarantees it on every API route.
The declaration now says so, with a comment stating the
assumption.
What is still loose: about 450 explicit any,
WebSocket messages that are untyped JSON with no runtime
validation, and request bodies that are destructured
without a schema. The next step is a discriminated union
for messages shared by client and server, validated at the
boundary.
Twenty-eight tests cover the execution policy (production refusals, path escapes through .., sibling directories and symlinks, secret stripping), the merge gate (green, failed, pending, no CI, unmergeable) and the job queue. The queue tests run against a real PostgreSQL started inside the test process: concurrent claims, lease expiry, retries, heartbeat, and a plan resuming on another worker. The deploy workflow type-checks and runs the tests before it ships anything.
Known limits
No operating-system isolation, so server-side execution is disabled instead of contained.
Single instance: the job worker, in-memory state and rate limits, and WebSocket routing all live in the web process, with no failover.
A chat command interrupted by a crash is failed, not re-run. An interrupted plan resumes, but re-runs the agent that was mid-run, and its GitHub writes are not idempotent.
Progress events have no replay, ordering guarantee or backpressure handling.
No row-level security in PostgreSQL. Tenant isolation is a filter in each query.
Roles are detected by keyword in an agent's prompt or name rather than stored as a typed field.
The daily token budget is not atomic and does not cover delegated calls.
The base URL of OpenAI-compatible and Ollama agents is not checked against internal addresses.
Twenty-eight tests, run before each deploy. Frontend TypeScript is not strict.
Author
Built and operated by Mickaël Poulhazan.
The repository is private for now. Access is given on
request.