Describe a change. Get a reviewed pull request.

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.

Open the app See how it works

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: a repository is selected, the prompt reads 'Fix dependabot vulnerabilities and push on dedicated branch', with shortcuts for top improvements, bug fixes, a security audit and a code review.
The Orchia home screen. Pick a repository, say what you want done.

How it works

  1. 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.

  2. 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

  3. 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

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.

orchestratorPlan   status=running
orchestratorAgent  developer   status=running
commandStep         github_read_file   src/parser.ts
commandStep         github_write_file  src/parser.ts, branch fix/orchia-m3k2x (auto-created)
orchestratorAgent  developer   status=done
orchestratorAgent  reviewer    status=running   dependsOn=[developer]
commandStep         get_pr_diff        #12
orchestratorAgent  reviewer    status=done
orchestratorAgent  devops      status=running   dependsOn=[reviewer]
commandStep         github_get_pr_status   ci=success mergeable=true
commandStep         github_merge_pr        squash
orchestratorAgent  devops      status=done
orchestratorPlan   status=done
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).

Orchia architecture and trust boundaries The browser and an optional bridge on the user's machine connect to a single Express process on the Orchia server. Express calls the agent manager in process. The agent manager calls model providers and the GitHub API, and reads and writes PostgreSQL. User's machine Browser React, Zustand Bridge (optional) CLI or File System Access API REST, WebSocket WebSocket /bridge Orchia server Express One PM2 process: cookie JWT auth, rate limits, REST, ws in-process calls and events Agent manager Model runners, plan scheduler, tool gate In memory: agent to user map, bridges, caches prompts API calls SQL Third parties Model providers Anthropic OpenAI OpenRouter GitHub API files, branches pull requests merge PostgreSQL Supabase all state, every row has user_id
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.

  1. Authenticate

    requireAuth verifies the access-token cookie (JWT, 15 minutes) and sets req.userId.

    Blocking
    Yes
    On failure
    401
    State
    None, stateless
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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.
export const SERVER_EXEC_ENABLED =
    process.env.NODE_ENV !== 'production' ||
    process.env.ALLOW_SERVER_EXEC === 'true';

export function isServerPathDenied(resolved: string, cwd?: string): boolean {
    if (!SERVER_EXEC_ENABLED) return true;
    if (!cwd) return false;
    const root = realpathOfExisting(cwd);
    const target = realpathOfExisting(resolved);
    return target !== root && !target.startsWith(root + path.sep);
}

backend/src/utils/execPolicy.ts

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

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

Author

Built and operated by Mickaël Poulhazan.

The repository is private for now. Access is given on request.