fx is a CLI-first coding agent written in Zig.
Contributions should preserve that direction:
-
CLI-first over terminal-IDE behavior
-
explicit contracts over ad hoc strings and branches
-
permission-first security model
-
small, reviewable changes
-
honest docs and status reporting
Requirements:
-
Zig
0.16.0+ -
interactive terminal for manual shell testing
-
a Vercel OAuth session via
fx loginfor model-backed flows. macOS Keychain API keys (viafx setup),AI_GATEWAY_API_KEY, andVERCEL_OIDC_TOKENare also supported
Common commands:
zig fmt src/
zig build
zig build test
zig build runKeep the local development loop focused: run the narrowest test that covers the changed path, build fx, and exercise the change using ./zig-out/bin/fx. The installed fx on PATH is not valid development evidence.
Once the focused checks pass, create a clean checkpoint commit, push the non-main feature branch, and open a draft PR immediately. The Full CI workflow runs the complete deterministic suite on native Linux x86_64, Linux aarch64, macOS x86_64, and macOS aarch64 runners. The native matrix builds, tests, and smoke-tests ReleaseSafe on every platform; formatting and the public-surface audit run in those ReleaseSafe jobs. Four duration-balanced, isolated ReleaseSafe E2E shards per platform use checked-in weights to assign every Bun test file once; files inside each shard run sequentially in separate Bun processes so terminal fixtures and process state cannot leak between files. A failed file receives one bounded retry after tmux is reset.
Standard PR CI reports ReleaseSafe Build & Test and deterministic E2E results. Do not mark the draft PR ready until all four Full CI jobs and the final ship gate have succeeded for the exact current commit. Each platform aggregate requires its ReleaseSafe native check and all four ReleaseSafe E2E shards. A result from an older commit does not count. Live model evals are separate from this gate because they require credentials and are not deterministic.
Changes to build.zig or scripts/pgso/ also run the native macOS arm64 PGSO candidate workflow. That lane produces retained size, behavior, and performance evidence but does not alter any release artifact or update channel. Its pinned toolchain, local reproduction command, corpus exclusions, and failure rules are documented in scripts/pgso/README.md.
Every pull request also receives informational ReleaseSafe binary-size comparisons for Linux x86_64, Linux arm64, macOS x86_64, and macOS arm64. Each comparison builds the pull request merge commit and base commit on the same native runner, reports exact file and ELF or Mach-O section deltas, and emits a warning at increases of 52,429 bytes (0.050000 MiB) or more. The warning requests investigation but does not replace the full PGSO release gate or reject a valid feature solely for adding code.
Every PR must carry exactly one label that describes its primary intent:
-
type: bug: fixes incorrect behavior -
type: feature: adds a new user-facing capability -
type: improvement: improves existing user-facing behavior -
type: docs: changes documentation only -
type: maintenance: changes internal tooling, dependencies, CI, or implementation structure without a user-facing behavior change -
type: release: prepares or repairs a release -
type: security: fixes or hardens a security boundary
If you cannot manage labels, a maintainer or repository agent will apply the label before review. For a mixed PR, choose the label that best describes why the PR exists. Keep the title as a clean imperative sentence and do not add bracketed type prefixes such as [bug] or [improvement].
-
src/main.zig: composition root only -
src/core/: contracts, runtimes, config, sessions, permissions, MCP, skills -
src/tools/: built-in tool implementations -
src/ui/: terminal rendering, event loop, input, transcript -
src/gateway/: AI Gateway client transport -
.fx/skills/: optional fx-native workspace-level skill root -
skills/: optional shared workspace-level skill root
Before adding a new feature, answer these first:
- Which module owns the behavior?
- What is the typed contract?
- Does it need persistence?
- Does it need both text and JSON output?
- What docs and tests land with it?
- How is its deterministic E2E owner classified for macOS arm64 PGSO?
If that is unclear, stop and define it first.
Classify every root tests/e2e/*.test.ts file in
scripts/pgso/corpus.json. Put common or performance-sensitive behavior in
training. Put important correctness, recovery, security, and rare behavior in
verification-only. Exclude only nondeterministic, live-network, credentialed,
sound-related, or harness-only coverage, and record the reason.
Tests added to an existing file inherit its classification. Reconsider that classification when a feature changes the file's product role, and remove stale entries when deleting a feature or E2E owner. Normal PR CI rejects missing, duplicate, stale, and unclassified files without running the full PGSO gate.
Config precedence (highest wins):
- Environment variables such as
FX_MODEL,FX_PERMISSION_MODE, andFX_MAX_AGENT_STEPS ~/.fx/settings.json→workspaces["<workspace_path>"](profile workspace overrides)~/.fx/settings.jsontop-level (profile global settings)<workspace>/.fx.json(committed project defaults)- Built-in defaults
Project .fx.json accepts only repo-safe defaults: sandbox, max_agent_steps, max_tool_result_bytes, and context. Profile-owned keys such as model, effort, fast_mode, slash_menu_categories, startup_scrollback, prompt_history, statusLine, skill_match_fuzzy, first_call_tool_choice, auto_upgrade, update_channel, permission_mode, and permission are ignored from project config before their values are parsed.
Runtime state lives under ~/.fx/:
-
~/.fx/sessions/<session-id>/session.json -
~/.fx/sessions/<session-id>/background/ -
~/.fx/sessions/<session-id>/subagent/ -
~/.fx/sessions/<session-id>/logs/
Sessions are global and portable across workspaces. Each session tracks a workspace_root that updates when resumed from a different directory.
Subagent children are ordinary sessions with their own ~/.fx/sessions/<child-id>/ directory and their own history. The subagent/ directory is per session on both sides of the relationship: a parent records create-operation identities there, and a child records its own control state there.
There are two distinct skill categories in fx:
-
fxroots that belong to the product itself:.fx/skills,skills/,~/.fx/skills -
compatibility roots discovered for other agent installs:
.opencode/skills,.codex/skills,.claude/skills,.agents/skills,.claw/skills, plus their global equivalents
/skills list should make that distinction visible to the user.
/skills add and /skills install install full skill directories into the profile-owned ~/.fx/skills managed root, not just SKILL.md. Workspace .fx/skills and skills/ remain discoverable project-local instructions, not managed install targets.
The interactive agent can also install skills via the install_skill tool when the user asks to install one in conversation, including pasted npx skills add ... syntax.
fx negotiates MCP 2026-07-28 over local stdio and stateless Streamable HTTP.
Version-scoped adapters retain legacy stdio,
2025-11-25/2025-06-18/2025-03-26 Streamable HTTP, and deprecated
2024-11-05 HTTP+SSE. Native sessions load runnable MCP configuration only
from the trusted profile:
~/.fx/mcp.json
Project .fx.json does not define runnable MCP commands, URLs, env, or secrets.
The core feature surface is Tools, Resources and Resource Templates, Prompts, Completion, pagination, cache-aware discovery, subscriptions, progress, cancellation, and form or URL elicitation. Keep modern and legacy protocol behavior in their existing version-scoped modules.
Tool schemas without $schema use JSON Schema 2020-12. fx also accepts the
canonical 2020-12 declaration and the canonical Draft 7 declaration used by
legacy SDKs, evaluates each with dialect-specific semantics, and rejects other
dialects or references that would require network fetching before publication.
The interactive surface supports:
-
/mcp list -
/mcp resource list <server> -
/mcp resource templates <server> -
/mcp resource read <server> <uri> -
/mcp resource complete <server> <uri-template> <variable> [value] -
/mcp prompt list <server> -
/mcp prompt get <server> <name> [arguments-json] -
/mcp prompt complete <server> <name> <argument> [value] -
/mcp add <name> <command> [args...] -
/mcp remove <name> -
/mcp reload -
/mcp auth <name> --open -
/mcp logout <name> -
/mcp path
Remote authentication supports configured bearer tokens and OAuth credential
discovery, persistence, refresh, scope challenges, and logout. Credential and
private-cache identity changes invalidate prior private state. macOS persists
OAuth credentials in Keychain and migrates the private profile credential file
only after verified publication. Other platforms use the 0600 credential file
under the 0700 profile directory. FX_DISABLE_KEYCHAIN=1 selects that portable
backend explicitly for deterministic tests and local troubleshooting.
Servers are optional by default. Required startup failures block the first TUI
or fx ask model request; optional failures publish a reduced, degraded
capability set. One-shot fx ask starts required servers before its first model
request and defers optional servers until the turn first performs an MCP
operation or delegates MCP capability to a child. /mcp list renders a bounded,
secret-free health snapshot.
/mcp reload evaluates a replacement before publication, so invalid config or
a required-server failure leaves the prior runtime callable.
ACP-provided servers are isolated to their owning ACP session. One-off and persistent subagents receive an immutable, permission-filtered view of the parent or ACP session's admitted MCP tools, resources, prompts, and completion capability. Missing, revoked, stale, or closed authority fails before transport.
Security is permission-first.
-
permission_modecontrols baseline behavior (ask,auto, oryolo) -
permissionconfig applies OpenCode-style wildcard rules -
session
alwaysapprovals are non-persistent; command approvals match the exact command while other grant categories may use patterns -
configured denies are evaluated before saved-session rules; an exact saved-session deny can narrow a configured allow, while an exact saved-session allow can satisfy an unresolved configured ask
-
/permissions remember allow|deny <tool-name> <arguments-json>confirms and stores an exact rule only for an active saved session;/permissionslists stable rule IDs and/permissions revoke <rule-id>removes one -
routine parsed development commands and reversible new-file creation can execute without model review after configured and saved-session policy; unknown, destructive, hidden, credential-bearing, public, and overwrite effects remain on the review or approval path
-
unresolved sensitive calls in
automode receive one exact automatic review using bounded current, first, and recent proven root requests; historical permission feedback is excluded, and non-allow, unavailable, and invalid review results return a recoverable denial with an opaque action-bound approval request when capacity permits -
the main agent may pass that exact request ID through
ask_user_questionto open the existing permission screen; generic question text cannot authorize an action, and the resulting once or always approval is revalidated and consumed only by the exact bound action -
bounded consecutive all-blocked response groups end the turn with ordinary blocker text and never open the human approval path automatically; any completed successful tool resets that recovery count, and configured and saved-session rules remain authoritative
-
the sandbox backend is configured independently; yolo uses an effective backend of
nonewithout rewriting the saved sandbox setting
Do not add new sensitive tool behavior without integrating it into src/core/permissions/permissions.zig.
Render bugs that appear during window resize are hard to reason about because the footer is inline (hugs the transcript) rather than pinned to the terminal bottom, and the 100 ms debounce can mask ordering mistakes. The testing rig covers three layers. Pick the lowest layer that can catch the bug.
Drive TranscriptRuntime against the built-in VT emulator. Assertions are on the cell grid after a sequence of writes and resize calls.
test "my resize scenario" {
var h = try Harness.init(std.testing.allocator, 80, 24, 4);
defer h.deinit();
try h.shell.initViewport(&h.metrics, 4);
try h.shell.writeTranscript(h.alloc, &h.metrics, 1024, "hello\n", true);
try h.flush();
try h.driveResize(60, 20, 4, true);
var row: std.ArrayList(u8) = .empty;
defer row.deinit(h.alloc);
try h.vt.rowText(1, &row);
try std.testing.expectEqualStrings("hello ", row.items);
}Add it to src/ui/resize_tests.zig. See the file header for what each Harness method does.
For bugs that only show up with a real terminal and a real signal (timing, input integration, terminal-emulator quirks), add a scenario to tests/e2e/tui-resize.test.ts using the helpers in tmux-helpers.ts:
test("my scenario", async () => {
session = await TmuxSession.create({ width: 120, height: 40 });
await session.waitForText(">", 10_000);
await session.resizeWindow(80, 30);
const grid = await session.capturePaneGrid();
expect(findFooter(grid)).not.toBeNull();
}, 30_000);For bugs reported by a user, have them run fx with FX_RECORD=<path>. Drop the tape in tests/e2e/tapes/<name>.fxtape and assert against fx replay --golden:
fx replay tests/e2e/tapes/my-bug.fxtape --golden tests/e2e/tapes/my-bug.txtCheck in the golden file and wire a regression test that re-runs fx replay in CI and diffs.
-
Do not grow
main.zigwith leaf feature logic -
Do not add hidden product state that only exists in the shell
-
Do not add a second execution path for the same feature without a clear reason
-
Do not document intended behavior as if it already exists
-
Do not commit generated state from
.fx/,.zig-cache/, orzig-out/ -
Do not add a general alternate-screen (
\x1b[?1049h/l) render path. fx is inline by design except for the five exclusive owner classes represented byAlternateScreenOwner: interactive tool-approval review, the full-transcript screen, catalog menus, the ctrl+x subagent manager, and the hosted child-terminal takeover. The terminal-session owner is entered only from the manager afterTerminalHostgrants the human write lease, has no permanent fx chrome, and must release the lease on detach. Every owner must leave or explicitly hand off the alternate buffer and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes before resolving, cancelling, or shutting down
Releases are triggered automatically when the version in src/main.zig changes on main:
- Edit
pub const version = "X.Y.Z";insrc/main.zig - Merge to
main - The release workflow checks if
vX.Y.Ztag exists; if not, it builds four platform binaries, creates the git tag, and publishes a GitHub Release with the binaries attached
The install script and fx upgrade fetch binaries from releases.fx.sh, backed by the public Vercel Blob CDN. No authentication or external CLI tools are required. The release workflow also publishes binaries to the CDN and updates latest.txt automatically.
After CI passes for a push to main, the dev release workflow publishes commit-addressed binaries and then updates dev.json. Dogfooders opt in with fx upgrade --channel dev; the choice is stored in their user settings and applies to manual upgrades, automatic upgrades, and the ctrl+g handoff. fx upgrade --channel stable returns to tagged releases. Dev publishing does not create tags or GitHub Releases.
Release notes are public product copy. Describe user-visible behavior, always spell the product fx, and omit contributor attribution, tracker references, repository or website work, delivery infrastructure, CI and test details, branch history, and implementation-only refactors. Use commits and pull requests as research evidence only. Changelog formatting and release-marker rules live in AGENTS.md.
Do not create tags manually. The workflow owns tag creation.
Startup latency benchmarks run automatically on every PR and push to main via .github/workflows/bench.yml.
The workflow builds a ReleaseSafe binary, then uses hyperfine to measure wall-clock time for six paths:
| Command | Budget | What it measures |
|---|---|---|
fx (startup) |
2ms | Binary launch through CLI dispatch (no TTY needed) |
fx help |
2ms | Minimal startup, pure text output |
fx status --json |
2ms | Config read + JSON serialization |
fx background --json |
2ms | Background record read |
fx doctor --json |
2ms | System checks, subprocess spawns |
fx sessions --json |
2ms | Session directory read |
On PRs the check fails if any command exceeds its budget.
The table is the authoritative Linux CI contract. Non-Linux local runs report raw means for comparison but do not assign a substitute product budget because the host process and dynamic-loader floor can independently exceed 2ms. The process baseline is diagnostic only and is never subtracted.
The startup benchmark uses FX_BENCH=1, which runs through CLI dispatch and exits before TTY initialization.
To run locally:
brew install hyperfine # macOS (one-time)
./benchmarks/startup.sh # full run (100 iterations, builds ReleaseSafe)
./benchmarks/startup.sh --quick # quick run (20 iterations)CI uses --runs 100 with a reduced warmup and skips the build step because the
workflow builds ReleaseSafe first. Results are written to
benchmarks/results/ (gitignored).
Minimum checklist:
- Run
zig fmt --check src/and the focused tests for the changed path. - Run
zig build, then exercise the change with./zig-out/bin/fx. - Push the feature branch and open a draft PR immediately.
- Require all four Full CI jobs and the final ship gate to pass for the exact current commit before marking the PR ready.
- Update
README.mdif user-facing behavior changed.