Architecture
Agent Zero is organized as a dependency-directed monorepo. The core decides what should happen; adapters decide how external systems communicate with it; the runner controls what is allowed to happen to a checkout.
Source-control adapters ─┐
CLI adapter ─────────────┼──> agent runtime ──> runner boundary ──> isolated checkout
packages/api ────────────┘ │
├──> model abstraction ──> provider
└──> shared contracts
apps/dashboard: UI + packages/api's router (oRPC + OpenAPI) + Better Auth ──> database package ──> Postgres
Nuxt marketing ───> public site (no inbound dependencies)
Dependency direction
sharedcontains stable data contracts and must not import feature packages.config,models,source-control, andrunnerimplement focused capabilities around shared contracts.agentcomposes policies and state transitions without knowing HTTP or terminal details.cliis an entry-point adapter. It may depend on the runtime, but the runtime must not depend on it.databaseowns the schema, the Drizzle client, and the migrations. It is the only package that talks to Postgres, and it holds no policy.authholds authentication policy and a Better Auth options factory, and reads the store throughdatabase. It does not depend on the runtime.packages/apicomposes the runtime, source-control, models, and config adapters into one router. It may depend on all of them; none of them may depend on it. It does not depend onauth.apps/dashboardis the entry-point adapter and composition root: a Nuxt app whoseserver/directory servespackages/api's router and, through its ownserver/auth.config.tscomposingpackages/auth's options, is the only process that opens the database, throughpackages/database.apps/marketingis a frontend-only Nuxt site with no dependents and no dependencies beyondpackages/i18n. Nothing may import it.
If a change creates a reverse dependency, move the shared contract inward instead of importing an adapter into the runtime.
API package
packages/api is the library apps/dashboard's server reads from: it composes the agent runtime, source-control adapter, model abstraction, and config into one typed oRPC router (health, tasks.list, tasks.get, tasks.create, approvals.decide) and a control-plane operations layer (runTask, TaskScheduler, TaskStore). It holds no HTTP host of its own and does not depend on packages/auth — apps/dashboard/server/ is the only place that constructs a transport handler from it, which keeps the router and its authorization rules identical regardless of which wire protocol serves a given request.
Procedures validate at the boundary with Zod and then delegate; they never invoke a shell or touch a checkout, because runTask is the only place that resolves policy and constructs a runner. A hosted RunnerPool lease is optional and still yields nothing but a Runner. EvlogHandlerPlugin, shared by every transport through one AsyncLocalStorage-backed logger (packages/api/src/orpc/logging.ts), attaches structured request logs; procedures read it defensively (requestLoggerStorage?.getStore()?.set(...)) so router tests that call procedures directly through createRouterClient, without a transport's plugin attached, still pass.
Marketing boundary
apps/marketing is the public site and holds the weakest position in the graph: no persistence, no credentials, no session, and no runtime-package imports. It links to the dashboard by origin rather than importing anything from it, so the two deploy and fail independently.
It differs from the dashboard in exactly one respect. The dashboard renders with SSR because its session cookie is scoped to its own origin, so the server resolves it directly from the incoming request; the marketing site renders on the server too, but for a different reason — being crawlable is the entire point of it, so it prerenders every route rather than depending on a live request. That gives it a Nitro server, but the only routes on it are the ones @nuxtjs/seo generates — robots.txt and the sitemaps. Anything that needs to read or write state belongs behind the dashboard's server/ routes, not here.
Dashboard and control-plane boundary
apps/dashboard is the composition root and the only entry-point adapter with HTTP capability: a Nuxt app whose server/ directory hosts
| Route | Purpose |
|---|---|
/rpc/** | packages/api's router over the typed oRPC RPC transport |
/api/v1/** | The same router over OpenAPI/REST (OpenAPIHandler); docs at /api/v1/docs, spec at /api/v1/openapi.json |
/api/auth/** | The Better Auth handler, mounted by @onmax/nuxt-better-auth from server/auth.config.ts |
/rpc/** and /api/v1/** serve the exact same rpcRouter and therefore the exact same authorization rules; only the wire protocol differs. .meta(openapi(...)) metadata on each procedure (method, path, tags) exists purely for the OpenAPI transport and has no effect on the RPC transport — it is attached through a real, regularly-imported function rather than the @orpc/openapi package's alternative bare side-effect import, because Nitro's production bundler tree-shakes an unused side-effect import away even though the package's own sideEffects field marks it as one to keep.
Mutations fail closed behind operator-issued bearer credentials (AGENT_ZERO_CONTROL_PLANE_TOKENS, comma-separated name:token pairs). tasks.create additionally requires the target repository path to appear in AGENT_ZERO_CONTROL_PLANE_REPOSITORIES, so an HTTP caller cannot point a run at an arbitrary server-local path, and the requested execution mode to be granted to the principal via AGENT_ZERO_CONTROL_PLANE_MODES (comma-separated name:mode|mode grants; without one a principal is limited to the non-writable observe and suggest modes). Approval decisions record the authenticated principal's name rather than a wire-supplied actor. Reads stay open for the dashboard. This bearer-token scheme is independent of the Better Auth session that protects the dashboard UI itself.
Task persistence is a narrow KeyValueStorage contract adapted over the ViteHub KV Runtime Helper (apps/dashboard/nuxt.config.ts registers vite-hub/nuxt, composing ViteHub into Nuxt's own Nitro build), so the filesystem driver, Cloudflare KV, Deno KV, or Upstash stays interchangeable. Records are redacted on the way in and hold no review input and no checkout path, so task history cannot become a credential or filesystem leak. TaskScheduler bounds concurrency globally and per repository, and rejects work once the queue is exhausted rather than growing without limit.
Transport concerns stop at the route handlers: headers, status mapping, and request objects never reach a runtime package.
Authentication boundary
Authentication follows the same adapter rule at the package level, but not at the process level: Better Auth is mounted in-process by apps/dashboard's /api/auth/** route (server/auth.config.ts), the only route in the app that resolves packages/auth's environment options — including the connection string, through packages/database — and the signing secret (NUXT_BETTER_AUTH_SECRET, required in production; BETTER_AUTH_SECRET only works as a development fallback) and therefore the only part of the app that opens a connection to Postgres, the only database in the repository. Every other route reaches storage exclusively through the KeyValueStorage contract. packages/auth holds the policy: authBetterAuthOptions builds the database, policy, and provider options Better Auth needs, deliberately omitting secret, baseURL, and trustedOrigins so the @onmax/nuxt-better-auth module — which resolves those itself and constructs the actual instance — cannot diverge from it. createAuth, which does build a full standalone instance, remains for callers that own their own secret and origin, such as the Better Auth CLI's schema-generation entry point; nothing in apps/dashboard's request path uses it. packages/auth's ./config subpath stays free of database dependencies so the login page can read feature flags without bundling one.
Invitations (AUTH_ENABLE_INVITATIONS) are the Better Enrollment plugin, composed by the same factory and under the same rule: packages/auth decides policy and declares delivery structurally, and apps/dashboard's server/auth.config.ts binds packages/mail to it. The plugin's mode is auto-detected from the sign-up policy rather than configured a second time — with AUTH_ENABLE_SIGNUP off every sign-up route is closed and invitations are the only way in, and with it on they degrade to role and organization grants — so the two cannot drift apart. Enabling Better Enrollment fails startup without both a mail transport and a dashboard origin: a private invitation's link is deliberately never returned to whoever created it, so email is the only path the token travels, and an undeliverable invitation reaches nobody at all rather than failing loudly. Better Auth's separate organization plugin has no mail-delivery callback in this composition and does not depend on the dashboard origin. A public invitation still returns its shareable link once from create or resend, while Better Enrollment's sendPublicInvitation callback also sends the inviter a durable copy when it has an attributable email address; headless system invitations without one keep relying on the returned link. Every Better Enrollment link points at one path, /invite?token=, because what the redemption page must render is decided by the auth server when it reads the token; encoding the invitation's kind in the URL would both duplicate that decision and disclose it to anyone the link is forwarded to. That page renders whichever form the server's nextAction names and submits only the fields its requiredFields asks for, so one page covers private and public invitations, app and organization ones, and both modes. It is the one authenticated-surface route with no auth route rule: requiring a session would turn away the signed-out invitee it exists for, and requiring a guest would turn away the signed-in one accepting an organization invitation. Redemption deliberately does not start a session, so a newly created account is handed to the sign-in page with the credentials it just set — for a private invitation the page never learns the full address anyway, since invite.get returns it masked.
The app-wide user.role column belongs to this boundary too, and is distinct from member.role, which scopes a role to a single organization. Better Auth keeps multiple app-wide roles in one comma-separated string, and only invitation redemption merges into it; any other write replaces the whole set, so code that changes a role has to send the full set or it silently revokes the rest.
The dashboard renders with SSR. The session cookie is scoped to the app's own origin, so the server resolves it directly from the incoming request before the first paint, rather than rendering a signed-out shell that a client-side check then corrects.