| Key | Value |
|---|---|
| Project Name | facets |
| Project Slug | circleci/TXx3MQGFf8BTw9fgSHwVWi/RfHfmwgTVFBrv4ZDBMMifk |
| Git Remote URL | git@github.com:agent-facets/facets.git |
| Default Branch | main |
This repo hosts agentfacets.io via SST. The SST app name is agent-facets.
Production and non-production stages live in separate AWS accounts so a developer machine can never mutate production:
| Stage | Domain | AWS account | Who deploys |
|---|---|---|---|
main |
agentfacets.io (apex) |
dedicated prod agentfacets.io (445459853351) |
CircleCI only |
${stage} |
${stage}.staging.agentfacets.io |
agent-facets-staging |
developers |
${user} |
${user}.staging.agentfacets.io |
agent-facets-staging |
developers |
| Key | Value |
|---|---|
| App name | agent-facets |
| Prod account | 445459853351 (agentfacets.io) — main/apex only. SST never selects a profile for main (CircleCI deploys via OIDC; local main is refused). The agent-facets-prod profile is for manual AWS CLI operations against this account only. |
| Staging account | 705557196199 (staging.agentfacets.io) — all non-main; profile agent-facets-staging |
| Local profile | agent-facets-staging (SSO, ex-machina session) |
| Node runtime | nodejs24.x (matches mise.toml — single source) |
Do not use the shared
facet-cafeprofile name in this repo's tooling. It lives in a shared~/.aws/configand the siblingfacet.caferepo repoints it (e.g. to its own staging account). Use the repo-ownedagent-facets-prod/agent-facets-stagingprofiles, both keyed by account ID. Production formerly shared thefacet.cafeaccount (726911960883); it now has its own dedicatedagentfacets.ioaccount (445459853351). Theagent-facets-legacy-prodprofile (→726911960883) exists only for maintenance of any residual shared-account resources.
main is deployed only by CircleCI (via OIDC; no AWS profile).
sst.config.ts throws if you try to operate on the main stage locally, and
the main stage is permanently protected and its resources retained so
removing it requires a deliberate edit to sst.config.ts. The staging subdomain
staging.agentfacets.io is a Route53
hosted zone in the staging account, delegated via an NS record from the prod
agentfacets.io zone.
-
mise must be active.
mise.development.tomlsetsAWS_PROFILE=agent-facets-stagingfor local development. There is no.env.local. -
You need SSO access to the
agent-facets-stagingaccount. Add this profile to~/.aws/config(theex-machinasso-session block already exists) and runaws sso login --sso-session ex-machina:[profile agent-facets-staging] sso_session = ex-machina sso_account_id = 705557196199 sso_role_name = Admin region = us-east-1
-
bun installrunssst installautomatically (skipped in CI).
| Command | What it does |
|---|---|
bun sst dev |
SST dev mode for current $SST_STAGE |
bun sst deploy --stage <stage> |
Deploy to a named stage |
bun sst remove --stage <stage> |
Tear down a non-main stage |
Every push to main runs sst deploy --stage main via the deploy workflow
in .circleci/release/workflows/deploy.yml. Requires the sst CircleCI
context with AWS_ROLE_ARN. See scripts/deploy/README.md for the pipeline
flow and CircleCI's AWS OIDC docs for the one-time IAM setup.
Manual deploys are still supported for ad-hoc stages via the bun sst deploy --stage <stage> command.
sst.config.tsat repo root.infra/contains infra modules auto-imported bysst.config.ts.infra/tsconfig.jsonscopes TypeScript for infra code (extends SST's platform config).packages/landing/is the Vite + React landing site served from apex.packages/functions/hosts Lambda handlers (currentlysrc/install.handler).
agentfacets.ioA → SST-managed CloudFront (apex). Zone lives in the dedicated prodagentfacets.ioaccount (445459853351).www.agentfacets.io→ 301 to apex via SSTdomain.redirects.docs.agentfacets.ioCNAME → Mintlify custom-domain target (managed by SST ininfra/dns.ts).staging.agentfacets.io→ delegated (NS) from the prod zone to a hosted zone in theagent-facets-stagingaccount. All non-mainstages get${stage}.staging.agentfacets.iorecords created there by SST, so developer deploys never write to the prod zone.
Turborepo monorepo with Bun workspaces. Six packages under packages/,
organized as a three-layer architecture (protocol / engine / CLI).
The TypeScript reference implementation of the facet artifact specification. Public, Node-native (Node 22+), the only thing in this monorepo that gets published to npm. Contains the schemas, bytes-validators, integrity verification, deterministic archive format, hash algorithm, version-spec grammar, front-matter encoding, and pure build validators.
If we rewrote engine in another language tomorrow, every line in protocol
would survive untouched — that's the test for what belongs here. See
packages/protocol/AGENTS.md for the full rules.
src/
├── schemas/ # Arktype schemas (facet, project, lockfile, build, server)
├── loaders/ # Pure bytes-validators: validateFacetManifest, validateServerManifest, resolvePromptsFromMap
├── integrity/ # 3-check + 1-check verification, IntegrityResult types
├── build/ # Pure: detect-collisions, validate-content, validate-facets, content-hash, parseFacetArchive
├── sources/ # Just version-spec.ts (VersionSpec type + grammar + resolvesToLatest)
├── front-matter.ts # YAML front-matter extract/strip
├── index.ts # Curated public API
└── __tests__/ # Tests run on bun:test (devDep), but src/ runs on Node
The Bun-native CLI machinery. Private to this monorepo; never published.
One concrete implementation of the spec on a developer's machine. Other
implementations (a future Rust CLI, the cafe registry server) would have
their own engine equivalent. Engine consumes @agent-facets/protocol
for everything that's part of the spec.
If you'd rewrite this code in Rust as part of porting the CLI, it belongs here.
src/
├── adapters/ # Adapter machinery: bundler, placement, verify, loader, install-service, first-party list
├── sources/ # Source resolvers: parse + clone/fetch (facet + adapter), Source type, ParseError
├── install/ # Install machinery: journal, lockfile-guard, lockfile-io, materialize, run-install orchestrator
├── cache/ # ~/.facet/cache/ — content-addressed cache for fetched facet payloads
├── manifest/ # Pure JSON mutations + project-files I/O bridge for facets.json
├── registry/ # Registry HTTP client: metadata resolution, download/extract, version-spec rendering
├── scaffold/ # Scaffold generator: `facet create` machinery
├── self-update/ # Detect install method, run the right updater
├── edit/ # Edit context: reconcile, scanner, manifest-writer, context, operations
├── build/ # Build pipeline orchestrator (pipeline.ts, write-output.ts) + compress.ts (gzip — delivery only)
├── loaders/ # Path-based loader wrappers that read disk and call protocol's bytes-validators
└── index.ts # Curated engine-specific exports — do not re-export protocol
A note on duplication: sources/facet/ and sources/adapter/ each have
their own parser and git-clone helper today. They started life on the CLI
side and were lifted to engine as-is. Consolidating them into one
parameterized resolver is a deliberate follow-up — the rules differ today
(facet sources enforce project-tree containment; adapter sources don't),
and a unified API would need to make that variation explicit.
CLI binary (facet). Thin orchestration layer over @agent-facets/protocol
(data primitives) and @agent-facets/engine (CLI workflows): command
bindings, Ink-based TUI views, error formatting for the terminal.
Entry point: src/index.ts
src/
├── commands/ # Command bindings: add, adapter, build, create, edit, install, self-update
├── tui/ # Ink components, hooks, layouts, views, theme, gradient, editor
├── util/ # CLI presentation helpers (errors.ts → 3-line stderr format)
├── cli.ts # CLI entry point used by the run loop
├── run.ts # Top-level argv → command dispatch
├── help.ts # Help rendering
├── commands.ts # Command registry + alias resolution
├── version.ts # Build-time version constant
├── suggest.ts # "did you mean?" suggestions
├── index.ts # Process-level entry (handles unhandled errors, exit codes)
└── __tests__/ # End-to-end tests + a few cross-cutting unit tests
Adapter SDK for defining abstractions over AI coding tools. Entry point: src/index.ts
src/
├── define-adapter.ts # Factory function: defineAdapter()
├── types.ts # Adapter types
└── index.ts # Public API entry point
Brand colors and visual identity constants.
Shared primitives that cross the protocol / engine / adapter SDK / CLI boundary:
cross-cutting types (AssetType, Scope, Validated) and pure helpers with no
heavy dependencies (asset-name validation, text normalization, atomic file writes).
Private — not published to npm. @agent-facets/adapter and @agent-facets/protocol
both bundle common into their builds via tsdown's alwaysBundle so external
consumers see a single package surface; engine and cli import it normally as a
workspace dependency.
See packages/common/AGENTS.md for the rule on what does and doesn't belong here.
| Directory | Purpose |
|---|---|
docs/ |
Mintlify documentation site |
scripts/ |
Repo-level utility scripts |
openspec/ |
OpenSpec change management (specs, schemas, changes) |
Strategic Decision Records (SDRs) and Architectural Decision Records (ADRs) live in Notion. The authoritative databases and views are configured in .opencode/notion.json (keys: sdrs, sdr_events, sdr_relationships, adrs, adr_events). Consult these when you need strategic or architectural context for decisions affecting this project. See also Article III of openspec/config.yaml for ADR authority.
Default to using Bun instead of Node.js.
- Use
bun <file>instead ofnode <file>orts-node <file> - Use
bun testinstead ofjestorvitest - Use
bun build <file.html|file.ts|file.css>instead ofwebpackoresbuild - Use
bun installinstead ofnpm installoryarn installorpnpm install - Use
bun run <script>instead ofnpm run <script>oryarn run <script>orpnpm run <script> - Use
bun <package> <command>instead ofnpx <package> <command> - You MUST run OpenSpec commands with
bun openspec ...notnpx openspec ... - Bun automatically loads .env, so don't use dotenv.
Bun.serve()supports WebSockets, HTTPS, and routes. Don't useexpress.bun:sqlitefor SQLite. Don't usebetter-sqlite3.Bun.redisfor Redis. Don't useioredis.Bun.sqlfor Postgres. Don't usepgorpostgres.js.WebSocketis built-in. Don't usews.- Prefer
Bun.fileovernode:fs's readFile/writeFile - Bun.$
lsinstead of execa.
Do not throw errors to model expected outcomes. Return discriminated union results instead. Throwing is for genuinely unexpected, unrecoverable conditions (programmer bugs, environment-level failures the function has no contract for). Anything a caller can reasonably handle — validation failures, missing files, cache misses, integrity mismatches, parse errors, lock contention — is part of the function's contract and belongs in its return type.
This is not a stylistic preference. Thrown errors are invisible to the type system: TypeScript cannot tell you which functions throw, what they throw, or whether you handled it. Result types make every failure mode a static obligation — the compiler refuses to let you forget a case.
// Bad — failure is invisible to the type system; caller may forget
// to handle it; the throw is a side channel that bypasses the return
// type entirely.
export function loadConfig(path: string): Config {
if (!existsSync(path)) {
throw new ConfigNotFoundError(path)
}
// ...
}
// Good — failure is part of the contract. The compiler forces the
// caller to discriminate before reaching the success data.
export type LoadConfigResult =
| { ok: true; config: Config }
| { ok: false; reason: 'not-found'; path: string }
| { ok: false; reason: 'invalid'; errors: ValidationError[] }
export function loadConfig(path: string): LoadConfigResult {
if (!existsSync(path)) {
return { ok: false, reason: 'not-found', path }
}
// ...
}Failure data is pure data: a struct describing what went wrong, with
the fields a caller (or a UI layer) needs to render or branch on. No
Error instance, no stack trace, no message string the caller has to
parse.
Match these shapes. Do not invent new patterns.
packages/protocol/src/integrity/types.ts—IntegrityResult,IntegrityFailure. Pure-data failure shape; no thrown errors.packages/engine/src/install/lockfile-io.ts—LoadLockfileResultas{ ok: true; data; existed } | { ok: false; error }.packages/engine/src/install/run-install.ts—PlanFacetResultand the largerRunInstallResultdiscriminated union. Failures are typed bycodediscriminator with structured fields per code.@agent-facets/common'sValidated<T>type — the project-wide alias for "validated payload or list of errors."
- Programmer bugs and invariant violations: an
assertNeverexhaustiveness check in aswitchover a tagged union. Reaching that arm is a bug; the throw is a debug aid, not a control-flow mechanism. - Environment failures the function never claimed to handle: out of memory, the JS engine crashing, a syscall returning something the TypeScript types swore couldn't happen. These are the throws library authors catch and convert into result types at the boundary.
- Inside try/catch wrappers that immediately convert to result types:
e.g.,
try { JSON.parse(s) } catch { return { ok: false, ... } }. The throw is internal; it never escapes the function. The function's contract is still result-shaped.
- "I'll just throw a typed error class": still invisible to the type
system, still a side channel. A
class FooError extends Errordoes not solve the problem; it just makes the throw feel structured. Use a discriminated union member instead. - "The caller can wrap it in try/catch": pushing failure handling into runtime checks the compiler can't verify. The whole point of TypeScript is to make this kind of obligation static.
- "It's only for exceptional cases": every codebase that has ever
said this has, three years later, contained dozens of
try { ... } catch (e) { /* swallow */ }blocks. If the failure mode is part of the function's contract, it goes in the return type. "Exceptional" is in the eye of the caller. - Mixed contract — sometimes returns, sometimes throws: pick one. If any failure mode in a function returns a result, all of them should.
If a function throws something a caller might reasonably want to handle: flag it. Convert it to a result type. The diff is mechanical; the type system improvement is permanent.
Use bun check to run tests, linting, and typeschecking.
When bun check (or bun run lint) reports a Biome formatting error, run bun format to fix it — do NOT hand-edit whitespace, line wrapping, or trailing commas to satisfy the formatter. bun format runs biome check --write --unsafe . across all 432 files in a few hundred milliseconds; manually reflowing a call to one line is slower and error-prone. Edit by hand only for actual lint rule violations that bun format can't auto-fix.
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});Bun's expect(...).rejects.<matcher> and expect(...).resolves.<matcher> return promises. You MUST await the entire expression (or return it from the test). Without the outer await, Bun's test runner sees a synchronous return, the assertion promise never settles in scope, and a failing assertion silently passes — the test appears green but provides no guarantee.
The same rule applies to any promise-returning matcher.
Correct — the outer await makes the assertion actually run:
test("should handle async errors", async () => {
await expect(async () => {
await fetchUser("invalid-id");
}).rejects.toThrow("User not found");
});Wrong — no outer await. This test passes even when fetchUser doesn't throw:
test("should handle async errors", async () => {
expect(async () => {
await fetchUser("invalid-id");
}).rejects.toThrow("User not found");
});The same rule applies to .resolves.*:
// Correct
await expect(loadConfig()).resolves.toEqual({ ok: true })
// Wrong — silently passes if loadConfig rejects or returns the wrong value
expect(loadConfig()).resolves.toEqual({ ok: true })When narrowing a discriminated union in a test (the most common case:
proving result.ok === false so you can access result.failure), use
expect.unreachable() to fail the test if the narrowing precondition
doesn't hold. Do not use if (...) return or if (...) throw.
A silent return in a test body looks like a passing test — Bun's
runner sees no failed assertion and reports green. A failing test
that prints "pass" is worse than no test at all.
throw new Error('unreachable') works (it does fail the test) but is
verbose and hides intent behind a generic Error. expect.unreachable()
is the dedicated primitive for this exact case: it fails the test,
counts as an assertion, and reads as "I'm asserting this code path is
impossible."
Wrong — silent return; failing precondition reports as passing test:
test('failure carries the right shape', () => {
const result = doThing()
expect(result.ok).toBe(false)
if (result.ok) return // ← silently swallows a real bug
expect(result.failure.code).toBe('FOO')
})Wrong — verbose throw; works but reads like a comment:
test('failure carries the right shape', () => {
const result = doThing()
if (result.ok) throw new Error('unreachable')
expect(result.failure.code).toBe('FOO')
})Correct — expect.unreachable() is purpose-built for this:
test('failure carries the right shape', () => {
const result = doThing()
if (result.ok) expect.unreachable()
expect(result.failure.code).toBe('FOO')
})The same applies to nested narrowing on tagged unions:
test('failure has facet kind', () => {
const result = doThing()
if (result.ok) expect.unreachable()
if (result.failure.kind !== 'facet') expect.unreachable()
expect(result.failure.check).toBe('A')
})expect.unreachable() doesn't need a message argument in most cases —
the line number and surrounding test name already tell the reader
which precondition failed. Add a string only if the failure mode
needs explanation a glance at the line wouldn't give.
When a preceding expect(...).toBe(...) would assert the same
condition, drop it: the expect.unreachable() arm fails the test
just as loudly and avoids the redundant assertion. The narrowing
itself is the assertion.
The check pipeline (bun check) orchestrates test, types, lint, and other tasks via Turborepo. Caching rules:
buildis cached by default. The CLI package (packages/cli) overridesoutputsto[]in its package-levelturbo.json— Turbo caches the hash (knows the build succeeded) but never uploads the ~63 MB compiled binary to the remote cache.testandtypesare cached and never depend onbuild. End-to-end tests that need a compiled binary live in a separatetest:e2etask — see "Test conventions" below.- Package-level overrides live in
packages/<name>/turbo.json.
*.test.tsfiles are unit tests. They import from source (../index.ts, notdist/) and never depend onbuild.*.e2e.test.tsfiles are end-to-end tests. They may spawn compiled binaries or read fromdist/. They run viatest:e2e, whichdependsOn: ["^build"](upstream package builds). The CLI'stest:e2escript inlines its own build (bun run build && bun test ...) so the compiled binary is produced fresh without making Turbo's cache depend on the large artifact.bun checkis the canonical entry point — it runs lint, types, unit tests, e2e tests, and the root-levelscripts/tests via Turbo.bun testat the repo root tests files inscripts/only (configured via rootbunfig.toml[test] root). For per-package work usebun test --cwd packages/<pkg>(unit only) orbun run --cwd packages/<pkg> test:e2e.- The
testscript in each package excludes e2e files viabun test --path-ignore-patterns '**/*.e2e.test.ts'(set per-package inpackage.json).
The CLI compiled binary (~63 MB) is too large for the Lambda-based Turbo remote cache. To handle this, packages/cli/turbo.json sets outputs: [] on the build task — Turbo caches the hash (knows the build succeeded for these inputs) but never tries to upload or download the binary. The test:e2e script inlines bun run build && so the binary is produced fresh when e2e tests run, without poisoning the Turbo cache chain.
- Add
"test": "bun test"and"types": "tsc --noEmit"scripts to itspackage.jsonso turbo picks them up for thecheckpipeline. - If the package has end-to-end tests that depend on build output, name them
*.e2e.test.ts, add atest:e2escript that inlines the build (bun run build && bun test ...), and ensure the rootturbo.json'stest:e2etask hasdependsOn: ["^build"](upstream builds only). Seepackages/cli/for an example. - If the package's build output is too large for remote cache, set
"outputs": []in the package-levelturbo.jsonso Turbo caches the hash without uploading artifacts.
Use HTML imports with Bun.serve(). Don't use vite. HTML imports fully support React, CSS, Tailwind.
Server:
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})HTML files can import .tsx, .jsx, or .js files directly and Bun's bundler will transpile & bundle automatically. <link> tags can point to stylesheets and Bun's CSS bundler will bundle.
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>With the following frontend.tsx:
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);Then, run index.ts
bun --hot ./index.tsFor more information, read the Bun API docs in node_modules/bun-types/docs/**.mdx.
When spawning subagents, never delegate the same inputs you received to a copy of yourself. This causes infinite recursive delegation.
- Bad: Agent receives "Explore X, Y, and Z" → spawns subagent with "Explore X, Y, and Z"
- Good: Agent receives "Explore X, Y, and Z" → spawns three subagents: "Explore X", "Explore Y", "Explore Z"
Decompose tasks into smaller, distinct sub-questions before delegating. Each subagent must receive a narrower, well-scoped slice of the original task — never the full task verbatim.
An Explore agent is spawned with the following context:
In the codebase at , investigate how sessions are stored, pruned, or deleted. I need very thorough findings on:
- Where sessions are stored (filesystem, database, memory?) - find the storage layer
- Any code that deletes, prunes, or cleans up sessions (search for delete/remove/cleanup/prune related to sessions)
- Any startup/initialization code that might clean up old sessions on boot
- How the
pruneconfig option works - does it only prune tool outputs from context window, or does it delete actual session records from storage?- Any connection between
OPENCODE_DISABLE_PRUNEenv var and session lifecycle
Spawn one subagent with the full context verbatim.
Spawn 5 Explore subagents, one per question:
- Subagent 1: "In the codebase at , where are sessions stored? Find the storage layer — filesystem, database, memory, etc. Return exact file paths and line numbers."
- Subagent 2: "In the codebase at , find any code that deletes, prunes, or cleans up sessions. Search for delete/remove/cleanup/prune related to sessions. Return exact file paths and line numbers."
- Subagent 3: "In the codebase at , find any startup or initialization code that cleans up old sessions on boot. Return exact file paths and line numbers."
- Subagent 4: "In the codebase at , how does the
pruneconfig option work? Does it only prune tool outputs from the context window, or does it delete actual session records from storage? Return exact file paths and line numbers." - Subagent 5: "In the codebase at , is there any connection between the
OPENCODE_DISABLE_PRUNEenv var and session lifecycle? Return exact file paths and line numbers."