From c05a0ba7432fe17cf12bba4dba83265068c07d3e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 1/9] docs(quickstart): make the README and AGENTS.md sequences runnable as written An audit ran every documented af-stack invocation against the real CLI; this commit takes the root-level findings. - README's fork-branding line passed `--logo ./logo.png`, a file no clone contains; since the logo is copied before brand.yaml is written, the whole init aborted with nothing applied. The runnable line is now `af-stack init --name "Acme AI" --color "#2563EB"` with the logo as an opt-in comment. Same in examples/starter/README.md. - README sent readers to the operator console without the seeded login it requires in the default saas mode. It now gives the credentials, how to seed different ones before first boot, and that `af-stack mode personal` turns login off. The two dashboard source comments that claimed this was already documented now are true. - AGENTS.md's proof-of-wiring curl is written against `supportdesk.echo`, but `af-stack init --name` rewrites the agent node id, so the call target vanishes on every branded fork. The prose now explains the `.echo` shape and how to list what is registered. - AGENTS.md listed `adapter list` under "no key"; it needs a running runtime and an operator key, as does billing. Recategorised. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- AGENTS.md | 32 ++++++++++++-------- README.md | 10 ++++-- apps/dashboard/src/app/(auth)/login/page.tsx | 5 +-- apps/dashboard/src/lib/bootstrap-operator.ts | 3 +- examples/starter/README.md | 15 ++++----- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8da678e3..1b91c0b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,13 @@ The default local URLs: | AgentField control plane | `http://localhost:8081/` | Agent registry + traces | | MinIO console | `http://localhost:9001/` | Dev object storage | -Prove the wiring without any key (the default `supportdesk` agent ships a -no-key `echo` reasoner for exactly this — the heavier `sample` agent lives -behind the `advanced` compose profile): +Prove the wiring without any key — the default agent ships a no-key `echo` +reasoner for exactly this (the heavier `sample` agent lives behind the +`advanced` compose profile). The reasoner path is `.echo`, where +`` is the `NODE_ID` set on the `supportdesk-agent` service in +`docker-compose.yml`. `af-stack init --name` rewrites that node id to your +slug, so on a branded fork use the new one — a plain GET on +`/api/v1/agents` (no key required) lists what is actually registered. ```bash curl -X POST http://localhost:8080/api/v1/agents/supportdesk.echo \ @@ -104,19 +108,21 @@ The 10 critical rules live in for agents. Configure once with env (`AF_STACK_URL`, `AF_STACK_API_KEY`) and drive everything. -- **Scaffold / lifecycle** (no key): `init`, `dev`, `mode`, `upgrade` - (`--check` for a dry run), `agent|module|plugin new`, `adapter list`, +- **Scaffold / lifecycle** (no runtime, no key): `init`, `dev`, `mode`, + `upgrade` (`--check` for a dry run), `agent|module|plugin new`, `deploy `. -- **Billing** (agent-first): `af-stack billing plan set --id pro --name Pro - --price 29 --budget 25 --entitlement seats=5 --default` auto-provisions - the Stripe Product + Price — no dashboard, no copy-pasted price IDs. See - [`docs/billing.md`](docs/billing.md). -- **Operator surface** (needs an operator key — mint one with `af-stack - operator key`): `keys`, `agents`, `reasoners`, `runs`, `logs`, `errors`, - `audit`, `sessions`, `tenants`, `activity`. Reference: +- **Billing** (agent-first; needs an operator key): `af-stack billing plan + set --id pro --name Pro --price 29 --budget 25 --entitlement seats=5 + --default` auto-provisions the Stripe Product + Price — no dashboard, no + copy-pasted price IDs. See [`docs/billing.md`](docs/billing.md). +- **Operator surface** (needs a running runtime + an operator key — mint one + with `af-stack operator key`; in `personal` mode the key is not + required): `keys`, `agents`, `reasoners`, `runs`, `logs`, `errors`, + `audit`, `sessions`, `tenants`, `activity`, `adapter list`. Reference: [`docs/cli-admin.md`](docs/cli-admin.md). - **MCP**: `af-stack mcp list|add|remove|call` manages MCP servers - registered with the runtime; `mcp call` takes/emits JSON. + registered with the runtime (it needs that runtime running); `mcp call` + takes/emits JSON. Errors are structured: every failure carries a stable `code`, a `message`, and a `request_id` (e.g. `[BUDGET_EXCEEDED] ...`). The machine-readable API diff --git a/README.md b/README.md index f4bebc1e..7d819d52 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,12 @@ Prefer not to pipe an installer into a shell? [Inspect it first](scripts/install or run `go install github.com/Agent-Field/backai/services/cli/cmd/af-stack@latest`. Open the customer app first at `http://localhost:34000`, then inspect what it -did in the operator console at `http://localhost:33000`. +did in the operator console at `http://localhost:33000` — sign in with +`operator@af-stack.local` / `changeme123`. Set +`AF_STACK_DEFAULT_OPERATOR_EMAIL` / `AF_STACK_DEFAULT_OPERATOR_PASSWORD` in +`.env` _before_ the first boot to seed different credentials; the seed only +runs while no operator exists, so change the password from the console +afterwards. `af-stack mode personal` turns the login off entirely. No model key is required. The first run uses a deterministic demo provider but still exercises the real gateway, tenant context, cost ledger, customer app, @@ -138,7 +143,8 @@ af-stack init my-ai-product # Or brand a full fork and hand it to your coding agent. These run inside # a clone of this repo; that clone is where the four surfaces below live. git clone https://github.com/Agent-Field/backai acme-ai && cd acme-ai -af-stack init --name "Acme AI" --color "#2563EB" --logo ./logo.png +af-stack init --name "Acme AI" --color "#2563EB" +# optional: --logo ./your-logo.svg sets the light+dark mark in brand.yaml af-stack agent new researcher ``` diff --git a/apps/dashboard/src/app/(auth)/login/page.tsx b/apps/dashboard/src/app/(auth)/login/page.tsx index 7bda582c..e48d7f3e 100644 --- a/apps/dashboard/src/app/(auth)/login/page.tsx +++ b/apps/dashboard/src/app/(auth)/login/page.tsx @@ -4,8 +4,9 @@ import { getDashboardSSOConfig } from "@/lib/sso" import { LoginForm } from "./login-form" // Server component. A default operator account is seeded at boot -// (lib/bootstrap-operator.ts) and documented in the README, so there is no -// first-run setup wizard to divert to — we always render the sign-in form. +// (lib/bootstrap-operator.ts) and its credentials are documented in the +// README quickstart, so there is no first-run setup wizard to divert to — +// we always render the sign-in form. export const dynamic = "force-dynamic" export default async function LoginPage() { diff --git a/apps/dashboard/src/lib/bootstrap-operator.ts b/apps/dashboard/src/lib/bootstrap-operator.ts index 1b0c1a23..f6fc7e1a 100644 --- a/apps/dashboard/src/lib/bootstrap-operator.ts +++ b/apps/dashboard/src/lib/bootstrap-operator.ts @@ -14,7 +14,8 @@ // every route (/login, /, …) bounced to /setup forever. Seeding a known // account removes that failure mode entirely. // -// Credentials come from env and are documented in the README / .env.example: +// Credentials come from env and are documented in the README quickstart, +// AGENTS.md and .env.example: // AF_STACK_DEFAULT_OPERATOR_EMAIL (default: operator@af-stack.local) // AF_STACK_DEFAULT_OPERATOR_PASSWORD (default: changeme123) // AF_STACK_DEFAULT_OPERATOR_NAME (default: Default Operator) diff --git a/examples/starter/README.md b/examples/starter/README.md index 4ac46898..7b918b84 100644 --- a/examples/starter/README.md +++ b/examples/starter/README.md @@ -6,17 +6,18 @@ into your own backend. ## What you copy -| Surface | Starter path | Copy into your fork | -| ---------------- | ------------------------------------ | ------------------------------------------------------- | -| Agent | `agents/starter/` | `apps/backend/agents//` | -| Customer flow | `customer-app/first-action/page.tsx` | `apps/customer-app/src/app/(app)/first-action/page.tsx` | -| Dashboard plugin | `dashboard-plugin/` | `apps/dashboard/plugins//` | -| Workload module | `workload-module/` | `workload-modules//` | +| Surface | Starter path | Copy into your fork | +| ---------------- | ------------------------------------ | ------------------------------------------------- | +| Agent | `agents/starter/` | `apps/backend/agents//` | +| Customer flow | `customer-app/first-action/page.tsx` | `apps/customer-app/src/app/first-action/page.tsx` | +| Dashboard plugin | `dashboard-plugin/` | `apps/dashboard/plugins//` | +| Workload module | `workload-module/` | `workload-modules//` | Start by branding the fork: ```bash -af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png +af-stack init --name "DocuChat" --color "#0A66C2" +# optional: --logo ./your-logo.svg sets the light+dark mark in brand.yaml ``` Then copy the starter pieces you want and rename `starter` to your From e9503dd886592902b77655969a5cf33f1396b80d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 2/9] docs(cli): bring the CLI reference back in line with the binary - `secrets` and `db` were listed as "planned / not yet shipped"; both ship and cli-admin.md documents them. - Scaffold and deploy blocks now state the checkout precondition, and `af-stack init --logo` no longer points at a file no clone has. - Drop the retracted `backai.dev/install.sh` one-liner and the claim that `af-stack serve` is the server mode (no such command). - `operator create` needs DATABASE_URL before it runs, not after. - The app-developer table no longer promises `--json` for `db` and notes db's checkout precondition; `adapter new` and the `agent|module validate` subcommands are documented. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- docs/cli-admin.md | 34 +++++++++++++++++++++++-------- docs/cli-distribution.md | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/docs/cli-admin.md b/docs/cli-admin.md index e32270dc..a7c782eb 100644 --- a/docs/cli-admin.md +++ b/docs/cli-admin.md @@ -13,27 +13,33 @@ Each command's section states which key it wants. ## Setup -Two environment variables control every admin call: +Two environment variables control every admin call over REST; a third, +`DATABASE_URL`, is what the handful of commands that bypass REST and talk to +Postgres directly need: | Env var | Purpose | Default | | ------------------ | ----------------------------------------------------------- | ------------------------ | | `AF_STACK_URL` | Runtime base URL. The CLI appends `/api/v1` to it. | `http://localhost:8080` | | `AF_STACK_API_KEY` | Bearer token — must be an **operator** key for admin cmds. | (unset) | +| `DATABASE_URL` | Postgres DSN for the commands that go straight to the DB: `operator create`, `operator key`, `db *`. `AF_STACK_DATABASE_URL` is accepted as an alias. | (unset) | Requests go to `${AF_STACK_URL}/api/v1` with `Authorization: Bearer ${AF_STACK_API_KEY}`. ### Minting an operator key -Operator keys are minted directly against the database, so the bootstrap +Operator keys are minted directly against the database, so **both** bootstrap commands need `DATABASE_URL` set (they do **not** go through the REST API): ```bash +# 0. Point the bootstrap commands at Postgres (they talk to the DB directly, +# not the REST API). AF_STACK_DATABASE_URL is accepted as an alias. +export DATABASE_URL=postgres://... + # 1. Allow an operator (records the email as operator-eligible) af-stack operator create --email founder@example.com -# 2. Mint an operator API key (needs DATABASE_URL for direct DB access) -export DATABASE_URL=postgres://... +# 2. Mint an operator API key af-stack operator key --owner # --owner grants the operator:owner scope # 3. Use the printed key for every admin command @@ -333,18 +339,30 @@ spell this out. `--json` emits `{ "created": ["jobs/."] }`. ## Diagnostics & migrations -These app-developer commands round out the CLI; each has its own `--json` -schema where it reports state. +These app-developer commands round out the CLI. `status`, `doctor` and `test` +each emit a stable `--json` report; `db` streams goose output instead and takes +`--dry-run` to preview the invocation. | Command | What it does | Key | | ------------------ | ------------------------------------------------------------ | --- | | `status [--json]` | Compact "is the stack up and how is it configured" snapshot | optional | | `doctor [--json]` | Environment + runtime health checks | optional | | `test [--json]` | Shippable-fork gates (module manifests, migration RLS lint) | none | -| `db diff\|push\|generate\|reset` | Runtime + workload-module migrations via goose | `DATABASE_URL` | +| `db diff\|push\|generate\|reset` | Runtime + workload-module migrations via goose | `DATABASE_URL` (or `AF_STACK_DATABASE_URL`) + in-checkout | + +`db` resolves migration directories relative to a BackAI checkout, so run it +from inside your clone — outside one it exits `2` with `db: must run from +inside a BackAI checkout (or pass --dir )`, before `DATABASE_URL` +is even consulted. The only escape hatch is `--dir `; `--module +` and `--all` are resolved under the repo root and still require the +checkout. `diff`, `push` and `reset` take `--dir --module --all --dry-run` +(and `reset` additionally requires `--yes` unless it is a `--dry-run`); +`generate ` takes only `--dir --module --dry-run`. `db status` is an +alias of `db diff`, and `db push` is goose `up`. Anything that actually runs +also needs `goose` on your PATH. ```bash af-stack status --json -af-stack db diff # applied vs pending migrations +af-stack db diff # from inside your clone: applied vs pending migrations af-stack db reset --yes # DESTRUCTIVE: roll every migration back ``` diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index c314397e..857fada1 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -65,11 +65,9 @@ dashboard, the dev is "in." ### Install CLI for power features -```bash -curl -fsSL https://backai.dev/install.sh | bash -``` - -One line. Same as AF. Sets up `af-stack` on PATH. +One line — the install script from +[Install (available now)](#install-available-now) above. It puts `af-stack` +on your PATH. ## Distribution channels @@ -109,23 +107,39 @@ Every command below exists in the current binary (see ```bash # Fork bootstrap + dev loop (run inside a clone of this repo) -af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png +af-stack init --name "DocuChat" --color "#0A66C2" +# optional: --logo ./your-logo.svg sets the light+dark mark in brand.yaml af-stack dev --detach af-stack mode personal|saas # auth+billing off ⇄ multi-tenant SaaS af-stack upgrade [--check] # pull latest upstream into this fork -# Scaffolds +# Scaffolds (run inside a clone of this repo) af-stack agent new af-stack module new af-stack plugin new +af-stack adapter new [name] [--dir ] # remote-adapter sidecar (no checkout needed) + +# Validate a scaffold — offline, no runtime and no key +# (exit 0 valid, 5 failed validation, 4 directory missing, 2 bad args) +af-stack module validate [--json] # e.g. workload-modules/notes +af-stack agent validate [--json] # e.g. apps/backend/agents/supportdesk # Tools af-stack mcp list/add/remove/call af-stack adapter list +# Tenant secrets vault (AF_STACK_API_KEY = tenant key) +af-stack secrets set [--value-stdin] [--description] +af-stack secrets list [--json] # metadata + secret: refs only + +# Migrations (goose; needs DATABASE_URL or AF_STACK_DATABASE_URL, and a checkout or --dir) +af-stack db diff|push|generate|reset # `status` aliases `diff`, `push` aliases goose up + # Identity + multi-tenancy (operator/keys/tenants/sessions) +# Both operator commands talk to Postgres directly, so both need +# DATABASE_URL (or AF_STACK_DATABASE_URL) — not a running runtime. af-stack operator create --email # allow a dashboard operator -af-stack operator key [--owner] # mint an operator API key (needs DATABASE_URL) +af-stack operator key [--owner] # mint an operator API key af-stack keys list/issue/rotate/revoke/spend af-stack tenants list af-stack sessions list/revoke @@ -142,12 +156,16 @@ af-stack agents list af-stack reasoners af-stack activity --tenant -# Deploy +# Deploy (run inside a clone of this repo) af-stack deploy helm|fly|railway|render ``` Every *shipped* CLI command maps to a documented REST endpoint or admin -SDK call. Operators can script via CLI; programmers can script via SDK. +SDK call. Operators can script via CLI; programmers can script via SDK. Flags, +REST endpoints and exit codes live in +[`docs/cli-admin.md`](cli-admin.md) — see [Secrets](cli-admin.md#secrets) and +[Diagnostics & migrations](cli-admin.md#diagnostics--migrations) for the two +groups above. > **Fork upgrade gotcha.** The compiled `bin/af-stack` committed in an > older fork predates newer subcommands. Rebuild the CLI before running @@ -163,8 +181,8 @@ roadmap. Don't assume they exist. ```bash af-stack user create/list/disable # planned -af-stack secrets set/get/list/delete/rotate # planned — secrets are managed via API / dashboard today -af-stack db migrate/rollback/status # planned — migrations run automatically at runtime boot +af-stack secrets get/delete/rotate # planned — set/list ship today; the runtime exposes metadata GET, DELETE and /rotate over REST +af-stack db down # planned — `af-stack db reset` rolls all the way back today af-stack import-module # planned af-stack self-update # planned — use `af-stack upgrade` to pull upstream into a fork ``` @@ -200,7 +218,7 @@ Trigger on git tag push. | bunx | Niche audience | | pipx | Wrong tool for cross-language CLI | | Auto-update daemon | User-explicit only | -| Multiple binaries (CLI + server separate) | Single binary, modal commands (`af-stack serve` is the server) | +| A CLI Docker image | The CLI is a single static Go binary from Releases; the runtime, dashboard and customer-app ship as their own images (`ghcr.io/agent-field/af-stack-*`) | ## Reference From 57ed03312763cfe5bbd67f55135b4f04462d7a6b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 3/9] docs(dx): state preconditions and stop describing things that do not exist - run.md's quick start ran `af-stack dev` with no clone step; it and the hub now start inside a checkout. `--no-open` is described as it behaves. - theming.md claimed `init --logo` copies the logo into the app public paths and runs generate:brand; on a fresh clone it writes brand.yaml and brand/logo.* and skips generation until deps are installed. - adapters.md presented `adapter list` as offline; it needs a running runtime and an operator key. `adapter new` is documented. - sdk-strategy.md's "not in any SDK" list named CLI commands that do not exist; dashboard-plugins.md and stack.md pointed at a `cost-explorer` example plugin that was never in the repo. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- docs/adapters/AUTHORING.md | 20 ++++++++++++++++---- docs/dashboard-plugins.md | 8 ++++---- docs/dx/README.md | 5 +++-- docs/dx/adapters.md | 7 +++++++ docs/dx/run.md | 12 +++++++++--- docs/sdk-strategy.md | 15 +++++++++++---- docs/stack.md | 2 +- docs/theming.md | 15 +++++++++++---- 8 files changed, 62 insertions(+), 22 deletions(-) diff --git a/docs/adapters/AUTHORING.md b/docs/adapters/AUTHORING.md index 6cb139af..b3b41736 100644 --- a/docs/adapters/AUTHORING.md +++ b/docs/adapters/AUTHORING.md @@ -6,12 +6,18 @@ ## TL;DR -1. Pick a **slot** (`sandbox`, `storage`, `notifications`, `secrets`, - `billing`, or `multimodal`). +1. Pick a **slot**. The CLI accepts ten: `sandbox`, `storage`, + `notifications`, `secrets`, `billing`, `multimodal`, `logs`, `traces`, + `metrics`, `errors`. (§1 below details the first six; the four + observability slots are covered in `protocols/`.) 2. Read the **universal contract** ([`PROTOCOL.md`](PROTOCOL.md)) and the **per-slot specification** (`protocols/-v1.md`). -3. Implement the HTTP protocol in **any language**. The protocol is - JSON over HTTP/1.1 with SSE for streaming endpoints. +3. **Start from the scaffold**: `af-stack adapter new [name]` + writes a skeleton that already serves `/healthz`, `/v1/capabilities` + and `/v1/info` (FastAPI; `--dir ` picks where it lands, and no + BackAI checkout is needed). Then implement the per-slot HTTP protocol + in **any language** — it is JSON over HTTP/1.1 with SSE for streaming + endpoints. 4. Run the **conformance harness**: `backai-adapter-conformance --slot --url http://localhost:PORT` 5. Ship a container image. Operators plug you in by setting env vars: @@ -41,6 +47,12 @@ speaks the protocol to your sidecar. Pick the one that matches what you want to provide. Each slot has its own protocol spec; the universal contract applies to all of them. +`af-stack adapter new` also scaffolds the four observability slots — +`logs` (built-in: ring buffer, Loki), `traces` (Tempo), `metrics` +(Prometheus) and `errors` (GlitchTip) — each of which takes +`AF_STACK__ADAPTER=remote` the same way. Slots the CLI does **not** +accept include `llm-chat` and `auth`; both exit 1. + If your service doesn't fit any slot, it's probably a **workload module** or a **dashboard plugin** — see `docs/ARCHITECTURE.md` §10.4 and §10.5. diff --git a/docs/dashboard-plugins.md b/docs/dashboard-plugins.md index f7eb04dc..72920767 100644 --- a/docs/dashboard-plugins.md +++ b/docs/dashboard-plugins.md @@ -131,10 +131,10 @@ Field reference: Default-export a React component. Server components can use `api.*` helpers directly; client components should hydrate from server-rendered data. The example -[`apps/dashboard/plugins/cost-explorer/page.tsx`](../apps/dashboard/plugins/cost-explorer/page.tsx) -shows the recommended pattern: fetch with `Promise.allSettled`, degrade -gracefully when the runtime is unreachable, reuse the shared -`formatCurrency` helper from `(admin)/operate/cost/_components/format.ts`. +[`examples/01-notable/dashboard-plugin/page.tsx`](../examples/01-notable/dashboard-plugin/page.tsx) +shows the recommended pattern: fetch server-side so no credential reaches +the browser, and degrade gracefully to a calm empty state when the upstream +service is unreachable. ### Run diff --git a/docs/dx/README.md b/docs/dx/README.md index 976710a0..2a3a0047 100644 --- a/docs/dx/README.md +++ b/docs/dx/README.md @@ -8,7 +8,7 @@ source — where this hub and older prose disagree, this hub wins. ```bash git clone https://github.com/Agent-Field/backai my-app && cd my-app -af-stack init --name "My App" # brand the fork: brand.yaml, logos, default agent +af-stack init --name "My App" # brand the fork: brand.yaml + default agent name (add --logo/--color to set those) af-stack dev # preflight ports + docker compose up # … edit one of the four surfaces (below) … af-stack deploy helm # ship it (helm | fly | railway | render) @@ -29,7 +29,7 @@ You build by editing one of four places. Everything else is platform. | --- | --- | --- | | **Agent** (AgentField reasoner) | `apps/backend/agents//` | `af-stack agent new ` | | **Customer app** (product UI) | `apps/customer-app/` | edit directly | -| **Workload module** (backend routes/crons/migrations) — *scaffold today; runtime auto-mounting is roadmap* | `workload-modules//` | `af-stack module new ` | +| **Workload module** (backend resources + migrations) — *scaffolds ship `enabled: false`; set `enabled: true` in `backai.module.yaml` (or list the id in `AF_STACK_WORKLOAD_MODULES`) and restart to mount it* | `workload-modules//` | `af-stack module new ` | | **Dashboard plugin** (operator UI) | `apps/dashboard/plugins//` | `af-stack plugin new ` | Or **don't build in the repo at all**: point your existing app at the @@ -64,6 +64,7 @@ Full breakdown in [sdk.md](sdk.md). | Topic | Doc | | --- | --- | +| Operator CLI + minting an operator key | [../cli-admin.md](../cli-admin.md) | | Deploying (helm/fly/railway/render) | [../deploy.md](../deploy.md) | | Multi-tenancy & RLS | [../multi-tenancy.md](../multi-tenancy.md) | | Workload modules (full contract) | [../workload-modules.md](../workload-modules.md) | diff --git a/docs/dx/adapters.md b/docs/dx/adapters.md index b996c7f4..d8395ba2 100644 --- a/docs/dx/adapters.md +++ b/docs/dx/adapters.md @@ -13,6 +13,13 @@ CLI-first: `af-stack adapter list` prints the live registry (what's plugged in per slot, its health, and the env var to change it). It reads `GET /api/v1/admin/adapters`, so it can never drift from a static table. +It is an operator command: it needs the runtime up (`af-stack dev`), +`AF_STACK_URL` (default `http://localhost:8080`), and `AF_STACK_API_KEY` +set to an operator key — mint one with +[`af-stack operator key`](../cli-admin.md#minting-an-operator-key) (needs +`DATABASE_URL`). The seeded operator in [run.md](run.md) is a dashboard +login, not an API key. + ## The swappable slots | Slot | Selector env | Values | Default | diff --git a/docs/dx/run.md b/docs/dx/run.md index 23ddb08a..3bca8045 100644 --- a/docs/dx/run.md +++ b/docs/dx/run.md @@ -3,16 +3,22 @@ ## Quick start ```bash +# from inside your clone of the BackAI repo af-stack dev ``` -That's the whole thing. `af-stack dev`: +From inside the clone, that's the whole thing — see the +[golden path](README.md) for the `git clone` line. Run it anywhere else and +it exits 1 with `must run from inside an AF Stack checkout`. `af-stack dev`: 1. Runs a **port preflight** (`scripts/preflight.mjs --fix`) — finds a free host port for each service, writes the overrides into `.env`, and sets `COMPOSE_PROJECT_NAME`. Skip it with `--no-preflight`. -2. Runs `docker compose up`. Add `--detach` to background it; add - `--no-open` to not pop the dashboard. +2. Runs `docker compose up` and prints the local URL map. Add `--detach` + to background it — in detached mode it also opens the **customer app** + (`http://localhost:34000` by default) in your browser; `--no-open` + suppresses that. In the foreground nothing is opened, so `--no-open` on + its own does nothing. Prefer raw compose? `docker compose up` works too — but then you own port conflicts yourself. diff --git a/docs/sdk-strategy.md b/docs/sdk-strategy.md index 5361cf25..48dafb66 100644 --- a/docs/sdk-strategy.md +++ b/docs/sdk-strategy.md @@ -177,14 +177,21 @@ suite.admin.harness.run(prompt, provider, tools, max_budget_usd) ## Not in any SDK (CLI + dashboard + REST) -- Schema migrations → `af-stack db migrate` -- Module enable/disable → `af-stack module enable X` +- Schema migrations → `af-stack db diff` (preview) / `af-stack db push` + (apply) / `af-stack db generate ` +- Module enable/disable → set `enabled: true` in `backai.module.yaml` or + add the id to `modules.workload_modules` (env + `AF_STACK_WORKLOAD_MODULES`) and restart; the CLI only scaffolds + (`af-stack module new `) - Adapter swap → edit `config.yaml`, restart -- Log tailing → `af-stack logs tail` or dashboard +- Recent-log reads → `af-stack logs --tail 100` or dashboard (nothing + follows the stream) - Live trace inspection → dashboard - Cost dashboards → dashboard - Stripe billing portal → embedded link -- Plugin install → `af-stack plugin install ` +- Plugin install → not shipped; dashboard plugins are scanned at build time + from `apps/dashboard/plugins/`, and `af-stack plugin new ` scaffolds + one in place Power users who need any of these in code can hit the REST endpoints. diff --git a/docs/stack.md b/docs/stack.md index f1bd4dc9..823170bf 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -216,7 +216,7 @@ Five extension points, one per typical thing you'd add: |---|---|---| | Add an AI agent | ④ Intelligence | Drop `apps/backend/agents//` — agent registers with AgentField at startup, callable at `/api/v1/agents/.` | | Add a dashboard tab | ① Client | Drop `apps/dashboard/plugins//plugin.ts` + `page.tsx` — sidebar picks it up at next build | -| Add a workload module | ③ API + ⑧ Data | Drop `workload-modules//manifest.yaml` + Go handler + migrations — loader mounts at `/workload//...` | +| Add a workload module | ③ API + ⑧ Data | Drop `workload-modules//backai.module.yaml` + migrations — the loader auto-generates tenant-scoped CRUD at `/api/v1/workload//` | | Swap an adapter | various | One env var (`AF_STACK_SANDBOX_ADAPTER=gvisor`, `AF_STACK_S3_ADAPTER=s3`, `AF_STACK_BILLING_ADAPTER=lago`, etc.) | | Theme it | ① Client | `apps/dashboard/src/app/brand.css` with CSS variable overrides — every shadcn primitive + chart inherits | diff --git a/docs/theming.md b/docs/theming.md index b124eb7f..02783246 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -4,12 +4,19 @@ BackAI branding starts in root [`brand.yaml`](../brand.yaml). For a new fork, prefer the CLI, run inside your clone of the repo: ```bash -af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png +af-stack init --name "DocuChat" --color "#0A66C2" +# optional: --logo ./your-logo.svg sets the light+dark mark in brand.yaml ``` -That writes `brand.yaml`, copies the logo into the generated app public -paths, and runs `pnpm run generate:brand`. If you edit `brand.yaml` -manually later, run `pnpm run generate:brand` again. +That writes `brand.yaml` and copies your logo to `brand/logo.` at the +repo root. The per-app copies under `apps/*/public/brand/` and the +generated `brand.css` / `lib/brand.ts` are produced by +`pnpm run generate:brand`, which `af-stack init` runs only when Node deps +are already installed — on a fresh clone it warns and skips. You usually +do not need to run it by hand: each app's `predev`/`prebuild` runs it, so +`af-stack dev` picks the branding up when it builds. To regenerate in your +working tree after editing `brand.yaml`: `pnpm install && pnpm run +generate:brand`. ## Where the variables live From 2aad7dba116a819ad01e8bf7d260771ec6ce4397 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 4/9] docs(modules): describe the module loader and manifest that actually ship The workload-module docs said there was no runtime loader and documented a manifest filename and schema the runtime rejects; product.md listed `af-stack harness list/install` as shipped CLI commands. Rewritten against the loader and manifest in the tree. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- docs/dx/build-app.md | 95 ++++++---- docs/product.md | 30 +-- docs/workload-modules.md | 385 +++++++++++++++++---------------------- 3 files changed, 243 insertions(+), 267 deletions(-) diff --git a/docs/dx/build-app.md b/docs/dx/build-app.md index 10f8a76c..0a5dcdc9 100644 --- a/docs/dx/build-app.md +++ b/docs/dx/build-app.md @@ -9,7 +9,7 @@ touch. Or skip the repo entirely and | --- | --- | | [Agent](#1-agent) | An AI reasoner (multi-step LLM logic) | | [Customer app](#2-customer-app) | Product UI your end-users see | -| [Workload module](#3-workload-module) | Backend routes / crons / migrations | +| [Workload module](#3-workload-module) | Tenant-scoped CRUD resources + migrations | | [Dashboard plugin](#4-dashboard-plugin) | A page in the operator console | --- @@ -57,16 +57,20 @@ in the Suite. Inside the agent, `app.*` gives you `app.reasoner` (define), **Lives in:** `apps/customer-app/` — a Next.js app. Edit it directly. -This is *your* product surface. From it you call the runtime via the -**`suite.*`** SDK (TypeScript). Full editing contract: +This is *your* product surface. From it you call the runtime through the +app's own same-origin proxy at `src/app/api/v1/[...path]/route.ts`, which +forwards the customer's session so the runtime resolves the right tenant. +Full editing contract: [`apps/customer-app/EDITING.md`](../../apps/customer-app/EDITING.md). **Edit freely:** -- `src/app/(app)/*` — pages and routes +- `src/app//page.tsx` — pages and routes (pattern: + `src/app/dashboard/page.tsx`; sign-in pages live under `src/app/(auth)/`) - `src/components/*` — product components - `src/lib/api.ts` — client helpers for runtime calls -- `src/components/layout/customer-sidebar.tsx` — nav links +- `src/components/app-sidebar.tsx` — nav links (the inline `items` array + passed to ``) Start a new logged-in workflow from `examples/starter/customer-app/first-action/page.tsx`. @@ -75,9 +79,14 @@ Start a new logged-in workflow from generated from root `brand.yaml` by `pnpm run generate:brand`. ```ts -import { suite } from "@af-stack/sdk" - -const out = await suite.agents.call("my-agent.summarize", { text }) +// `@af-stack/sdk` is not a dependency of apps/customer-app — call the +// proxy, which is same-origin and carries the session cookie. +const response = await fetch("/api/v1/agents/my-agent.summarize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: { text } }), +}) +const out = await response.json() ``` --- @@ -86,44 +95,64 @@ const out = await suite.agents.call("my-agent.summarize", { text }) **Lives in:** `workload-modules//` · **Scaffold:** `af-stack module new ` -A module is backend capability — HTTP routes, cron schedules, SQL -migrations. Only `manifest.yaml` is required. Full contract: -[../workload-modules.md](../workload-modules.md). +A module is a *declarative* backend capability: a manifest naming typed +resources, plus the versioned SQL that backs them. The runtime +auto-generates tenant-scoped CRUD from it — no handler code. Full +contract: [../workload-modules.md](../workload-modules.md). -> **Status: scaffold today; runtime mounting is on the roadmap.** -> `af-stack module new ` scaffolds the layout below (including a -> `handlers/routes.go.example` placeholder), but the runtime does **not** -> auto-load workload modules yet — there is no module loader and no -> `/workload//` route mounting wired in. Treat the route/cron/migration -> behavior described here as the design contract, not a live capability. +The runtime discovers `//backai.module.yaml` +(env `WORKLOAD_MODULES_PATH`, default `./workload-modules`), applies +`migrations/` at boot, statically RLS-lints every resource table, and +serves `/api/v1/workload//`. Inspect what it found at +`GET /api/v1/admin/modules`. + +**Files the scaffold writes:** ``` workload-modules// - manifest.yaml # required — metadata + requires + routes - migrations/ # optional — versioned SQL applied at boot - handlers/ # optional — Go (routes.go) or Python (handler.py) - crons/seed.yaml # optional — cron schedules seeded into suite_crons - config.schema.yaml # optional — operator-tunable config + backai.module.yaml # required — id, resources, typed fields + migrations/00001_init.sql # the table your resources are backed by + README.md ``` -**Minimal `manifest.yaml`:** +**Minimal `backai.module.yaml`:** ```yaml id: notes name: Notes version: 0.1.0 -requires: - - multi-tenancy - - llm-gateway -routes: - - method: POST - path: /notes - handler: notes.Create # -> mounted at /workload/notes/notes +description: Per-tenant notes. +enabled: false +migrations: migrations + +resources: + - name: notes # table: notes_notes (_) + fields: + - name: title + type: string + required: true + - name: done + type: bool + default: false ``` -Routes are *designed* to be prepended with `/workload//` so modules -never clash — this mounting is roadmap, not yet wired (see the status note -above). Crons declared here are covered in [jobs.md](jobs.md#crons). +Field types are `string | int | bool | timestamp | json`. `id`, +`tenant_id`, `created_at` and `updated_at` are reserved — the runtime +manages them, and your migration must create them alongside `ENABLE` + +`FORCE ROW LEVEL SECURITY` and a tenant-isolation policy. A table without +tenant isolation refuses to load and only that module is skipped; the +runtime keeps serving everything else. + +**Enable it.** Scaffolds ship `enabled: false`. Either flip that to `true` +in the manifest, or add the id to `modules.workload_modules` in +`config.yaml` (env `AF_STACK_WORKLOAD_MODULES=`), then restart. + +**Check it first.** `af-stack module validate workload-modules/` +(`--json` for a machine-readable report) runs the same manifest and RLS +gates offline, before you boot anything. + +The manifest has no cron field — schedule work through the crons API / +SDK instead ([jobs.md](jobs.md#crons)). --- diff --git a/docs/product.md b/docs/product.md index 73d7330e..ead2d18a 100644 --- a/docs/product.md +++ b/docs/product.md @@ -46,12 +46,12 @@ another provider key when you want live model calls through LiteLLM: | **Audit log** | Every admin mutation (tenant create/delete, api_key create/revoke, secret put/delete/reveal, budget set, membership change) writes a row with actor, IP, user agent, metadata. | | **MCP host** | stdio + SSE adapters with JSON-RPC framing, 5-minute tool catalogue refresh, per-tenant scoping, env from secrets vault via `secret:` prefix. | | **Skills** | Install bundles, attach to agents, query installed list. | -| **Harnesses** | Probe-only — detects whether claude-code/codex/gemini/opencode is available in the agent container and what auth it needs. | +| **Harnesses** | Probe-only — detects whether claude-code/codex/gemini/opencode is available in the agent container and what auth it needs. Surfaced by the operator dashboard's harness cards and `GET /api/v1/harnesses`, `GET /api/v1/harnesses/{provider}`, `POST /api/v1/harnesses/{provider}/probe`. There is no `af-stack harness` command on the operator CLI. | | **Operator dashboard** | Cost charts, run inspector, sandbox activity, memory browser, audit log, tenant drilldown, plugin system, theming via CSS variables. Plus operator pages for Secrets (vault CRUD + reveal/rotate), Crons (roster + trigger/pause), Flags, Cache (gateway hit rate + flush), Notifications (outbox + channels), and OAuth connections. | | **Customer-facing app** | Sign-up → help center → Support Chat → request history → billing/account pages. Runtime credentials stay internal to the app. Separate brand, same auth DB. | | **OpenAPI 3.1** | Auto-generated at `/openapi.json` with 86+ routes, 21 routes with curl+Python+TS code samples. | | **Python + TypeScript SDKs** | `suite.notifications.*`, `suite.webhooks.*`, `suite.billing.*`, `suite.sandbox.*`, `suite.memory.*`, `suite.tools.*` (MCP), `suite.admin.skills.*`, `suite.harnesses.*`. Pydantic + zod, close-but-not-identical parity: Python also ships `suite.crons.*`; TypeScript also ships `suite.activity.*` + `suite.flags.*`. The Go SDK is an empty stub (planned, not shipped). | -| **CLI** | `af-stack mcp list/add/remove/call`, `af-stack harness list/install`. | +| **CLI** | Scaffold + lifecycle (`init`, `dev`, `mode`, `upgrade`, `agent new`, `module new`, `plugin new`, `job new`, `deploy`), operator bootstrap (`operator create`, `operator key`), runtime admin (`keys`, `agents`, `reasoners`, `runs`, `logs`, `errors`, `audit`, `sessions`, `tenants`, `activity`, `adapter list`), plus `mcp list/add/remove/call`, `billing`, `connection`, `secrets`, `db`, `doctor`, `status`, `test`. `af-stack --help` prints the current list. | | **Helm chart** | Production-ready with HPA, NetworkPolicy, PDB, ServiceMonitor. Both `values-dev.yaml` (in-chart PG+MinIO) and `values-prod.yaml` (external everything). Helm lint passes both. | | **PaaS configs** | Fly.io (2 apps via flycast), Railway template, Render Blueprint, `docker-compose.prod.yml`, Caddy with auto-TLS. | | **Graceful shutdown** | `/health` is cheap liveness, `/ready` returns 503 during boot+drain+DB-down with proper `Retry-After`. SIGTERM triggers ordered shutdown: HTTP drain → workers cancel → DB close. | @@ -148,22 +148,26 @@ done. No fork. ### Adding a workload module ```yaml -# workload-modules/notes/manifest.yaml +# workload-modules/notes/backai.module.yaml id: notes name: Notes version: 0.1.0 -requires: [multi-tenancy, llm-gateway] -routes: - - { method: POST, path: /notes, handler: notes.Create } -meters: - - { name: notes_created, unit: count } +enabled: false +resources: + - name: notes # table: notes_notes + fields: + - { name: title, type: string, required: true } + - { name: done, type: bool, default: false } ``` -Go handler at `workload-modules/notes/handlers/notes.go`, migration at -`workload-modules/notes/migrations/00001_init.sql`. `af-stack module new` -scaffolds this layout today; the runtime-side dynamic loader that mounts -routes at `/workload/notes/...` and applies per-module migrations is still -being wired — see `docs/workload-modules.md` for the current status. +Migration at `workload-modules/notes/migrations/00001_notes.sql` creates +the table with `tenant_id` + forced RLS. At boot the runtime discovers the +manifest, RLS-lints and applies the migrations, and serves tenant-scoped +CRUD at `/api/v1/workload/notes/notes` — no handler code. Scaffolds ship +`enabled: false`, so flip it to `true` or add the id to +`modules.workload_modules` (env `AF_STACK_WORKLOAD_MODULES`) and restart. +`af-stack module validate workload-modules/notes` checks it offline; see +`docs/workload-modules.md` for the full contract. ### Swapping a default diff --git a/docs/workload-modules.md b/docs/workload-modules.md index 31af5883..51db7df6 100644 --- a/docs/workload-modules.md +++ b/docs/workload-modules.md @@ -3,256 +3,199 @@ Workload modules are the way BackAI pulls in domain-specific features (notes, podcast jobs, reactive enrichments, etc.) without forking the runtime. Each module is a directory you drop under -`workload-modules//`; the design is for the runtime to scan -`config.yaml` at boot, load each enabled module, register its routes + -migrations + crons, and expose them through the same auth + tenancy -chain as the built-in modules. - -The pattern was extracted from Example 01 (Notable) and Example 04 -(Podcast). The examples that ship in the repo are the canonical -reference for how a real workload module looks. - -> **Status (what's wired today).** This document describes the **intended -> design**. `af-stack module new ` scaffolds the directory layout, but -> the **runtime-side loader is not yet wired**: -> -> - The runtime only reads a `workload_modules:` list of ids from -> `config.yaml` (`services/runtime/internal/config/config.go`) and -> surfaces it on `GET /api/v1/modules`. There is no -> `services/runtime/internal/workload/` loader package yet, so no -> module's routes, migrations, or crons are auto-loaded at boot. -> - The **Go in-runtime handler** is the intended primary path, but the -> scaffold emits a disabled `handlers/routes.go.example` placeholder -> ("rename to `routes.go` when the workload handler package is enabled in -> your fork") — the `workload.Request` / `workload.Response` contract -> shown below does not exist in the tree yet. -> - The **Python-sidecar handler** and **per-module cron seeding** are -> design sketches, not shipped behavior. -> -> Custom backend routes today go through the core runtime or an AF agent. -> Treat every section below as the target design. +`workload-modules//`, and it is **declarative**: a manifest that names +typed resources, plus the versioned SQL that backs them. The runtime scans +that directory at boot, applies each enabled module's migrations, and +auto-generates tenant-scoped CRUD behind the same auth + tenancy chain as +the built-in surfaces. Straight CRUD needs no handler code at all. + +`workload-modules/notes/` is the worked reference that ships in the repo. +Copy it when you start your own. + +## What the runtime does at boot + +1. **Discover.** It scans `` (env + `WORKLOAD_MODULES_PATH`, default `./workload-modules`) for + `/backai.module.yaml`. That filename is fixed — nothing else is + discovered. +2. **Validate.** Each manifest is parsed with unknown keys rejected. An + invalid manifest disables *that module only*: the runtime logs it and + keeps serving everything else. +3. **RLS-lint.** Every `CREATE TABLE` in the module's migrations is + statically checked for a `tenant_id` column, `ENABLE` + `FORCE ROW + LEVEL SECURITY`, and at least one `CREATE POLICY`. A tenantless table + is refused *before* any DDL runs, and only that module is skipped. +4. **Migrate.** Pending `migrations/*.sql` are applied, each in its own + transaction, and recorded in the platform-owned + `suite_module_migrations` table keyed by `(module_id, version)`. +5. **Mount.** Each resource gets five routes under + `/api/v1/workload//`, registered in the OpenAPI spec. + +Inspect the result at `GET /api/v1/admin/modules` (operator key): id, +name, version, enabled, health, and migration state (`applied`, +`pending`, `error`, `skipped`) per discovered module. + +## Enabling a module + +Scaffolds — and the `notes` reference — ship `enabled: false`, so a +discovered module never auto-serves. A module is active when **either**: + +- its manifest sets `enabled: true`, **or** +- its id appears in the operator's enabled list: `modules.workload_modules` + in `config.yaml`, or the env override + `AF_STACK_WORKLOAD_MODULES=notes,billing-ops`. + +Restart the runtime to apply. Disabling removes the routes; it does not +drop the table or the data. ## Directory layout +`af-stack module new ` writes exactly three files: + ``` workload-modules// - manifest.yaml # required — declares the module's metadata - migrations/ # optional — versioned SQL applied at boot - 00001_init.sql - handlers/ # optional — Go or Python HTTP handlers - routes.go # for Go - handler.py # for Python - crons/ # optional — cron schedules seeded at boot - seed.yaml - config.schema.yaml # optional — schema for the operator-tunable - # block in the runtime's config.yaml + backai.module.yaml # required — the declarative manifest + migrations/00001_init.sql # the table(s) your resources are backed by + README.md ``` -Only `manifest.yaml` is required. A module can be 100% migrations + crons -if it doesn't need its own HTTP surface. +Migration files must be named `_.sql` with a numeric +prefix; they are applied in version order. A module may ship no migrations +at all, but a resource whose table does not exist fails on first query. -## `manifest.yaml` +## `backai.module.yaml` ```yaml id: notes name: Notes version: 0.1.0 -description: Per-tenant Markdown notes with summarize / suggest-tags - agent integrations. - -# Required platform features. Boot fails fast if any are off so the -# operator gets a clear error rather than mysterious 503s downstream. -requires: - - multi-tenancy - - llm-gateway - - memory - -# Optional: the routes the module wants to mount. The runtime prepends -# /workload// so routes don't clash across modules. -routes: - - method: POST - path: /notes - handler: notes.Create # references a function in handlers/ - - method: GET - path: /notes - handler: notes.List - - method: GET - path: /notes/{id} - handler: notes.Get - -# Optional: meters this module pushes through the billing subsystem. -# Declared up front so the dashboard's billing tab can render them -# without round-tripping the runtime. -meters: - - name: notable_notes_created - unit: count - description: One per POST /workload/notes. +description: Reference workload module — a tenant-scoped notes resource. +enabled: false +migrations: migrations # optional; defaults to "migrations" + +resources: + # Backing table follows the _ convention: notes_notes. + - name: notes + fields: + - name: title + type: string + required: true + - name: body + type: string + - name: done + type: bool + default: false ``` -## How the runtime loads it (intended design — not yet wired) - -The flow below is the design a future `services/runtime/internal/workload/` -loader will implement. Today the runtime only reads the `workload_modules:` -id list from `config.yaml`; none of the mount / migrate / seed steps below -run yet. At boot the loader will read `config.yaml`: - -```yaml -workload_modules: - - id: notes - enabled: true - config: - # Module-specific knobs read against config.schema.yaml. - max_note_size_kb: 256 -``` - -For each enabled entry: - -1. **Validate** the module's `requires:` against the live module flags. - Missing prereq → hard fail at boot. -2. **Apply migrations** under the suite's standard migration table - namespace (`workload__schema_migrations`) so they version - independently from core schema. -3. **Register routes** under `/workload//...`. They inherit the - tenant resolver + auth middleware. Handlers receive the resolved - tenant from the request context like any other route. -4. **Seed crons** declared in `crons/seed.yaml` into `suite_crons` so - they appear in the dashboard's Crons tab and the runtime - scheduler dispatches them on schedule. - -## Authoring a Go handler - -Workload modules have a tiny `WorkloadHandler` contract: - -```go -package notes - -import ( - "context" - - "github.com/Agent-Field/backai/services/runtime/internal/workload" -) - -type CreateInput struct { - Title string `json:"title"` - Body string `json:"body"` - Tags []string `json:"tags"` -} - -func Create(ctx context.Context, req workload.Request) (workload.Response, error) { - // req exposes: - // req.TenantID — resolved tenant id - // req.UserID — operator session, when set - // req.Body — raw JSON body - // req.DB — pgx pool already bound to the tenant context - // req.Billing — meter() / has_budget() - // req.Memory — put / get / search - // req.AgentField — invoke an agent by node id - - var in CreateInput - if err := req.Decode(&in); err != nil { - return req.BadRequest("invalid body"), nil - } - - // ...write to the per-tenant notes table... - - // Meter the action. Crashes here are isolated so the metering - // failure doesn't roll back the note write. - _ = req.Billing.Meter(ctx, "notable_notes_created", 1) - - return req.JSON(201, map[string]any{"id": newID}), nil -} -``` - -Handlers in `handlers/` are picked up by the loader via a register call -in `init()`. The convention is one file per resource: -`handlers/notes.go` registers `notes.Create`, `notes.List`, etc. - -## Authoring a Python handler +What the parser enforces: -For modules where the workload is Python-heavy (Notable's agents, deep -research, etc.), the handler can be a regular AF agent reasoner: +- `id`, `name`, `version` and at least one resource are required. `id` is + lowercase alphanumeric with `-` / `_` separators; `version` is + semver-ish (`N`, `N.N`, or `N.N.N` with an optional suffix). +- Field types are `string`, `int`, `bool`, `timestamp`, `json`. +- `id`, `tenant_id`, `created_at` and `updated_at` are **reserved** — the + runtime manages those columns, so a manifest must not redeclare them as + fields. +- Unknown keys are an error. The old imperative shape (`requires:`, + `routes:`, `handler:`, `meters:`) is not accepted; `af-stack module + validate` calls that shape out by name. -```python -# workload-modules/notes-py/handlers/notes_handler.py -from agentfield import Agent, AIConfig +Validate offline, before you boot anything: -app = Agent(node_id="notes-handler") - -@app.reasoner(tags=["http"]) -async def create_note(payload: dict[str, any]) -> dict[str, any]: - title = payload["title"] - body = payload["body"] - tenant_id = payload["_tenant_id"] # injected by the runtime - # ... - return {"id": new_id} +```bash +af-stack module validate workload-modules/notes # add --json for a report ``` -In the intended design the runtime proxies HTTP requests at -`/workload/notes-py/notes` to the reasoner, injecting the tenant id as -`_tenant_id` in the payload. This proxy path is **not wired yet** — a -Python workload today runs as a normal AF agent that you invoke through -`app.*` / `suite.agents.*`, not via a `/workload/...` route. - -## Calling agents from a workload handler - -Workload handlers can invoke AF agents the same way the rest of the -runtime does: - -```go -result, err := req.AgentField.Invoke(ctx, "summarize", map[string]any{ - "note_id": id, - "body": body, -}) +## The generated routes + +For the resource `notes` in the module `notes`: + +| Method | Path | Action | +| --- | --- | --- | +| GET | `/api/v1/workload/notes/notes` | list | +| POST | `/api/v1/workload/notes/notes` | create | +| GET | `/api/v1/workload/notes/notes/{id}` | get | +| PATCH | `/api/v1/workload/notes/notes/{id}` | update | +| DELETE | `/api/v1/workload/notes/notes/{id}` | delete | + +List takes `?limit=` (default 50, capped at 200) and `?offset=`, and +returns `{items, total, limit, offset, has_more}`. + +Every query is filtered by the tenant the request resolver bound, and the +table's RLS policy enforces the same thing inside Postgres. A client can +neither set `tenant_id` nor reach another tenant's rows. + +## The migration + +Your migration creates the backing table, and it has to satisfy the RLS +lint. Copy the shape the scaffold emits: + +```sql +create table if not exists notes_notes ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null, + title text not null, + body text, + done boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists notes_notes_tenant_idx + on notes_notes (tenant_id, created_at desc); + +alter table notes_notes enable row level security; +alter table notes_notes force row level security; + +create policy tenant_isolation on notes_notes + using ( + current_setting('app.bypass_rls', true) = 'on' + or tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid + ) + with check ( + current_setting('app.bypass_rls', true) = 'on' + or tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid + ); ``` -Cost from that invocation flows through the gateway and is attributed -to the calling tenant. +## Beyond CRUD -## Crons +The manifest is declarative only. There is no in-module Go or Python +handler contract, and no `crons:` field. When a module needs behavior +rather than storage, reach for the surface that owns it: -Crons are rows in the `suite_crons` table. The runtime's cron scheduler -(`services/runtime/internal/crons`, robfig/cron v3, 60s tick, multi-replica -safe) dispatches due rows via the jobs manager — that part is real, and you -create crons through the API / `suite.crons.*` SDK. In the intended design a -module ships a `crons/seed.yaml` that the loader upserts into `suite_crons` -at boot: - -```yaml -- name: notes-daily-digest - job_name: notes-daily-digest - schedule: "0 9 * * *" - args: - template: daily-digest -``` - -The per-module `crons/seed.yaml` boot-upsert is part of the loader that is -**not yet wired** — until then, seed crons via the API / SDK. +- **Custom logic / LLM work** → an AgentField agent under + `apps/backend/agents//`, invoked via `suite.agents.*`. +- **Scheduled work** → crons are rows in `suite_crons`, created through + the API / `suite.crons.*` SDK and dispatched by the runtime's scheduler + (robfig/cron v3, 60s tick, multi-replica safe). See + [dx/jobs.md](dx/jobs.md#crons). +- **Background work** → the River-backed jobs queue. ## Removing a module -Set `enabled: false` in `config.yaml`. The loader skips registration but -leaves data + crons in place so the operator can re-enable without loss. -To actually remove, delete the `workload-modules//` directory and -manually drop the data via your normal migration tooling. - -## Built-in modules in the repo +Set `enabled: false` in the manifest (and drop the id from the enabled +list). The routes disappear on the next boot; the data and the applied +migration rows stay, so you can re-enable without loss. To remove it for +good, delete the `workload-modules//` directory and drop the tables +with your own migration. -BackAI does not currently ship a ready workload module in this -directory. Shipwright's first slice is implemented as a core runtime -metadata API plus an AgentField-backed example under -`examples/02-shipwright/`; a future `workload-modules/git-workload/` -can add deeper branch / diff / PR primitives once the production GitHub -path lands. +## Modules in the repo -The Notable example is implemented as example-local handlers today. -When a workload module ships, copy-paste an entire `workload-modules//` -into your own deploy to vendor it. +- `workload-modules/notes/` — the worked reference: manifest, migration, + README. Ships `enabled: false`, so enable it before you call it. +- `workload-modules/git-workload/` — an empty placeholder directory, + reserved for deeper branch / diff / PR primitives once the production + GitHub path lands. Shipwright's first slice is implemented as a core + runtime metadata API plus an AgentField-backed example under + `examples/02-shipwright/`. -## Limits in v1 +## Limits - No hot reload — module changes require a runtime restart. -- No cross-module dependencies — modules can't import each other's - handlers (they CAN call each other's routes via internal HTTP). -- Python handlers run in the agent process pool, not a dedicated - sandbox, so they share the same fate domain. Use a sandbox adapter - for arbitrary code execution. +- Resources are flat CRUD: no joins, no custom filters, no validation + beyond field type and `required`. +- Modules can't import each other; they CAN call each other's routes over + internal HTTP. +- The runtime never generates DDL. Adding a field to a resource means + adding a migration for the column too. From a6cb2606834ac9cb61e29140a4833eebb210ea0d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 5/9] docs(runbooks): remove commands the operator CLI does not have The restore runbook (and scripts/restore.sh) told operators to run `af-stack migrate up`, which does not exist as a subcommand and would boot a second runtime; KMS rotation documented `af-stack secrets rotate-kms` and `AF_STACK_KMS_KEY_NEW`, which are unimplemented; the graceful-shutdown smoke test started "the runtime" with `af-stack &`, which is the operator CLI on PATH. Each now names the real command or says plainly that the capability is not implemented yet. Stale `supportdesk.echo` literals note that a branded fork renames the node. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- deploy/helm/af-stack/README.md | 36 +++++++---- .../src/content/docs/deploy/internals.md | 19 ++++-- .../content/docs/reference/backup-restore.md | 60 +++++++++++++------ docs/backup-restore.md | 59 ++++++++++++------ docs/deploy-internals.md | 19 ++++-- docs/multi-tenancy.md | 6 +- scripts/restore.sh | 13 ++-- 7 files changed, 150 insertions(+), 62 deletions(-) diff --git a/deploy/helm/af-stack/README.md b/deploy/helm/af-stack/README.md index ce0d6a13..efa1598d 100644 --- a/deploy/helm/af-stack/README.md +++ b/deploy/helm/af-stack/README.md @@ -212,18 +212,27 @@ kubectl -n af-stack patch hpa af-stack-runtime \ ### Rotate the KMS key -The runtime uses envelope encryption: rotating the KMS key requires -re-wrapping every stored secret. Procedure: - -1. Generate a new key: `openssl rand -hex 32` -2. Add it to your KMS Secret under a temporary key (e.g. `AF_STACK_KMS_KEY_NEW`). -3. Run the runtime's rotate-kms job (Phase 14.2 — when shipped): - `kubectl create job --from=cronjob/af-stack-rotate-kms one-shot-rotate` -4. Once the job completes, swap `AF_STACK_KMS_KEY` to the new value in the Secret. -5. `helm upgrade` (or `kubectl rollout restart deploy/af-stack-runtime`) to pick up the new key. - -Until Phase 14.2 lands, treat KMS keys as one-shot: only rotate by tearing the -secrets vault down and restoring from a re-encrypted dump. +**Automated rotation is not implemented.** The runtime loads one KEK at boot +and labels ciphertext with a fixed key id: there is no `af-stack secrets +rotate-kms`, nothing reads `AF_STACK_KMS_KEY_NEW`, and this chart ships no +rotate-kms CronJob. There is no dual-key window, so swapping the key before +you have exported the values makes every row in `suite_secrets` permanently +unrecoverable. + +The only safe order is export → swap → re-write: + +1. Back up the database. +2. **While the old key is still active**, read every secret out through the + audited reveal endpoint (`POST /api/v1/vault/secrets/{key}/reveal` per + tenant). The CLI has no reveal verb. +3. Generate the new key (`openssl rand -hex 32`), set `AF_STACK_KMS_KEY` to it + in the Secret, and `helm upgrade` (or + `kubectl rollout restart deploy/af-stack-runtime`) to pick it up. The vault + is unreadable from here until step 4 finishes. +4. Re-write each value with `af-stack secrets set --value-stdin`. +5. Archive the old key material — without it, anything missed in step 2 is gone. + +Full runbook: `docs/backup-restore.md`. ### Swap the storage adapter @@ -282,7 +291,8 @@ so upgrades are zero-downtime as long as your HPA `minReplicas >= 2`. ServiceEntry / TrafficSplit resources. Add manually if you run a mesh. - **NetworkPolicy assumes ingress-nginx by default.** Override `networkPolicy.ingressControllerSelector` for traefik / contour / cilium. -- **KMS rotation is manual** until Phase 14.2 ships the rotate-kms job. +- **KMS rotation is manual.** There is no rotate-kms job or CronJob; the + supported path is export → swap → re-write (see above). - **In-chart Postgres uses `emptyDir` in `values-dev.yaml`.** Data is lost on pod restart. Production must use external Postgres. - **No PersistentVolumeClaims on the runtime.** It is stateless by design. diff --git a/docs-site/src/content/docs/deploy/internals.md b/docs-site/src/content/docs/deploy/internals.md index 1c75e8d2..cbd77798 100644 --- a/docs-site/src/content/docs/deploy/internals.md +++ b/docs-site/src/content/docs/deploy/internals.md @@ -196,11 +196,13 @@ go test ./services/runtime/internal/server/... -count=1 go test ./services/runtime/... -count=1 ``` -Manual smoke test (local Docker): +Manual smoke test (local process): ```bash -# Start the runtime -af-stack & +# Build and start the RUNTIME binary. NOT the `af-stack` operator CLI that +# scripts/install.sh puts on PATH — different program, same name. +make build-runtime # -> bin/af-stack-runtime +./bin/af-stack-runtime & PID=$! # Verify liveness + readiness @@ -212,9 +214,18 @@ kill -TERM $PID & sleep 0.5 curl -s localhost:8080/ready # {"status":"draining","since_s":0,...} curl -s localhost:8080/health # {"status":"alive",...} (still 200) -curl -s -X POST localhost:8080/api/v1/agents/supportdesk.echo -d '{}' +# Any path except /health, /ready, /metrics and /openapi.json answers +# DRAINING while the drain is in progress. +curl -s localhost:8080/api/v1/agents # {"error":{"code":"DRAINING","message":"server is shutting down..."}} # Wait for process to exit wait $PID ``` + +No database is required: without `AF_STACK_DATABASE_URL` the runtime logs +`database URL not configured; running without persistent state` and still +serves `/health` and `/ready`, which is all this test needs. The drain +window is only observable while a request is in flight — with nothing in +flight the process exits on SIGTERM immediately, so start a slow request +first (or accept that the `draining` curls may race the exit). diff --git a/docs-site/src/content/docs/reference/backup-restore.md b/docs-site/src/content/docs/reference/backup-restore.md index 732a4158..e87dad42 100644 --- a/docs-site/src/content/docs/reference/backup-restore.md +++ b/docs-site/src/content/docs/reference/backup-restore.md @@ -104,14 +104,24 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ --no-privileges -d "$URL" ``` -After restore, **always** run the runtime migrations again — they're -idempotent and will catch any schema drift between the backup vintage -and the current code: +After restore, **always** restart the runtime — it applies every +pending core, workload-module and jobs migration on boot (over +`AF_STACK_MIGRATE_DATABASE_URL` when that is set) and exits non-zero if +they fail. Migrations are idempotent, so this also catches any schema +drift between the backup vintage and the current code: ```bash -docker compose run --rm runtime /usr/local/bin/af-stack migrate up +docker compose up -d --force-recreate runtime +docker compose logs runtime | grep "migrations applied" ``` +There is no `af-stack migrate` subcommand. If you are working inside a +clone of the repo, `af-stack db push --all` is a developer alternative — +but it needs a checkout, a `DATABASE_URL`, a separately installed +`goose`, `--all` to pick up workload-module migrations, and it does not +apply the River jobs migrations. Booting the runtime is the complete +path. + ## Storage backup For MinIO (in-cluster) — back up the bucket with `mc mirror`: @@ -135,23 +145,37 @@ mc mirror s3://your-prod-backup-bucket/af-stack-20260607/ af/af-stack ``` Sandbox run rows in PostgreSQL reference storage by URL. After a -restore, expect some signed-URL endpoints to 404 until you re-link or -mark old runs as archived — there's a `scripts/storage-relink.sh` -helper for this. +restore, expect the `*_url` columns to 404 if the bucket path changed. +There is no relink helper: either mirror the bucket back to the same +path (the command above does exactly that), or clear the stale URL +columns by hand. Do not try to "archive" the affected rows — +`suite_sandbox_runs.status` is constrained to +`queued | running | done | failed | timeout | killed`. ## KMS key rotation -The `AF_STACK_KMS_KEY` encrypts secret values inside `suite_secrets`. -To rotate: - -1. Set `AF_STACK_KMS_KEY_NEW=` alongside the existing - key. -2. Run `af-stack secrets rotate-kms` — re-encrypts every row with the - new key in a transaction (uses both old + new keys during the migration). -3. Restart the runtime with `AF_STACK_KMS_KEY` set to the new value - only. -4. Archive the old key in your password manager labelled - `-pre-`. +**KMS rotation is not implemented.** The runtime loads one KEK at boot +and labels ciphertext with a fixed key id; there is no `af-stack secrets +rotate-kms`, and `AF_STACK_KMS_KEY_NEW` is read by nothing (the same +caveat is in `deploy/helm/af-stack/README.md`). There is no dual-key +window, so **swapping the key before you have exported the values makes +every row in `suite_secrets` permanently unrecoverable.** + +The only safe order today is export → swap → re-write: + +1. Back up the database (see above). +2. **While the old key is still active**, read every secret out through + the audited reveal endpoint — `POST /api/v1/vault/secrets/{key}/reveal` + per tenant, or `POST /api/v1/secrets/{key}/reveal` with an operator + session for the default tenant. The CLI has no reveal verb. +3. Restart the runtime with the new `AF_STACK_KMS_KEY` (or the newly + wrapped cloud data key). The vault is unreadable from this point + until step 4 finishes — every existing row is ciphertext under the + old key. +4. Re-write each value: + `printf %s "$VALUE" | af-stack secrets set --value-stdin`. +5. Archive the old key material labelled `-pre-` — + without it, any row you missed in step 2 is gone. ## Backup verification diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 6344ae1f..8c330488 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -104,14 +104,24 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ --no-privileges -d "$URL" ``` -After restore, **always** run the runtime migrations again — they're -idempotent and will catch any schema drift between the backup vintage -and the current code: +After restore, **always** restart the runtime — it applies every +pending core, workload-module and jobs migration on boot (over +`AF_STACK_MIGRATE_DATABASE_URL` when that is set) and exits non-zero if +they fail. Migrations are idempotent, so this also catches any schema +drift between the backup vintage and the current code: ```bash -docker compose run --rm runtime /usr/local/bin/af-stack migrate up +docker compose up -d --force-recreate runtime +docker compose logs runtime | grep "migrations applied" ``` +There is no `af-stack migrate` subcommand. If you are working inside a +clone of the repo, `af-stack db push --all` is a developer alternative — +but it needs a checkout, a `DATABASE_URL`, a separately installed +`goose`, `--all` to pick up workload-module migrations, and it does not +apply the River jobs migrations. Booting the runtime is the complete +path. + ## Storage backup For MinIO (in-cluster) — back up the bucket with `mc mirror`: @@ -135,22 +145,37 @@ mc mirror s3://your-prod-backup-bucket/backai-20260607/ af/af-stack ``` Sandbox run rows in PostgreSQL reference storage by URL. After a -restore, expect some signed-URL endpoints to 404 until you re-link or -mark old runs as archived — there's a `scripts/storage-relink.sh` -helper for this. +restore, expect the `*_url` columns to 404 if the bucket path changed. +There is no relink helper: either mirror the bucket back to the same +path (the command above does exactly that), or clear the stale URL +columns by hand. Do not try to "archive" the affected rows — +`suite_sandbox_runs.status` is constrained to +`queued | running | done | failed | timeout | killed`. ## KMS key rotation -The active data key encrypts secret values inside `suite_secrets`. To -rotate: - -1. Set `AF_STACK_KMS_KEY_NEW=` for the env provider, or - configure a newly wrapped cloud BYOK data key alongside the current - one. -2. Run `af-stack secrets rotate-kms` — re-encrypts every row with the - new key in a transaction (uses both old + new keys during the migration). -3. Restart the runtime with only the new env key or wrapped data key. -4. Archive the old key material labelled `-pre-`. +**KMS rotation is not implemented.** The runtime loads one KEK at boot +and labels ciphertext with a fixed key id; there is no `af-stack secrets +rotate-kms`, and `AF_STACK_KMS_KEY_NEW` is read by nothing (see +[the Helm chart README](../deploy/helm/af-stack/README.md)). There is no +dual-key window, so **swapping the key before you have exported the +values makes every row in `suite_secrets` permanently unrecoverable.** + +The only safe order today is export → swap → re-write: + +1. Back up the database (see above). +2. **While the old key is still active**, read every secret out through + the audited reveal endpoint — `POST /api/v1/vault/secrets/{key}/reveal` + per tenant, or `POST /api/v1/secrets/{key}/reveal` with an operator + session for the default tenant. The CLI has no reveal verb. +3. Restart the runtime with the new `AF_STACK_KMS_KEY` (or the newly + wrapped cloud data key). The vault is unreadable from this point + until step 4 finishes — every existing row is ciphertext under the + old key. +4. Re-write each value: + `printf %s "$VALUE" | af-stack secrets set --value-stdin`. +5. Archive the old key material labelled `-pre-` — + without it, any row you missed in step 2 is gone. ## Backup verification diff --git a/docs/deploy-internals.md b/docs/deploy-internals.md index 76375c22..aaa2f68b 100644 --- a/docs/deploy-internals.md +++ b/docs/deploy-internals.md @@ -194,11 +194,13 @@ go test ./services/runtime/internal/server/... -count=1 go test ./services/runtime/... -count=1 ``` -Manual smoke test (local Docker): +Manual smoke test (local process): ```bash -# Start the runtime -af-stack & +# Build and start the RUNTIME binary. NOT the `af-stack` operator CLI that +# scripts/install.sh puts on PATH — different program, same name. +make build-runtime # -> bin/af-stack-runtime +./bin/af-stack-runtime & PID=$! # Verify liveness + readiness @@ -210,9 +212,18 @@ kill -TERM $PID & sleep 0.5 curl -s localhost:8080/ready # {"status":"draining","since_s":0,...} curl -s localhost:8080/health # {"status":"alive",...} (still 200) -curl -s -X POST localhost:8080/api/v1/agents/supportdesk.echo -d '{}' +# Any path except /health, /ready, /metrics and /openapi.json answers +# DRAINING while the drain is in progress. +curl -s localhost:8080/api/v1/agents # {"error":{"code":"DRAINING","message":"server is shutting down..."}} # Wait for process to exit wait $PID ``` + +No database is required: without `AF_STACK_DATABASE_URL` the runtime logs +`database URL not configured; running without persistent state` and still +serves `/health` and `/ready`, which is all this test needs. The drain +window is only observable while a request is in flight — with nothing in +flight the process exits on SIGTERM immediately, so start a slow request +first (or accept that the `draining` curls may race the exit). diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md index a7d25ce9..70f405ef 100644 --- a/docs/multi-tenancy.md +++ b/docs/multi-tenancy.md @@ -163,7 +163,11 @@ What it checks: API. 3. Issuing an API key returns `value` exactly once; listing keys does not leak it. -4. Both keys can invoke `supportdesk.echo` through the gateway. +4. Both keys can invoke `supportdesk.echo` through the gateway. (The + script hardcodes that reasoner path; if you renamed the default + agent's node id — `af-stack init --name` rewrites `NODE_ID` on the + `supportdesk-agent` service in `docker-compose.yml` — point it at + `.echo` instead.) 5. `GET /admin/audit?tenant=` contains acme's key id and **never** globex's key id (audit scope is per-tenant). 6. A secret written by acme is **not visible** when listed as globex diff --git a/scripts/restore.sh b/scripts/restore.sh index 7cec90ae..4e25f077 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # Restore the AF Stack runtime database from a pg_dump archive. # -# Drops + recreates objects via pg_restore --clean. Re-runs migrations -# after to catch any schema drift between the backup and the current -# code. +# Drops + recreates objects via pg_restore --clean. Restart the runtime +# afterwards: it applies every pending migration on boot, catching any +# schema drift between the backup and the current code. # # Usage: # ./scripts/restore.sh --url postgres://... --from /backups/af.sql.gz @@ -56,7 +56,10 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ --no-privileges -d "$URL" yellow "==> Schema may have moved on since the backup vintage." -yellow " Run the runtime migrations now to catch up:" -yellow " docker compose run --rm runtime /usr/local/bin/af-stack migrate up" +yellow " Restart the runtime to apply pending migrations (core, workload" +yellow " modules and jobs) — it runs them on boot and exits non-zero on" +yellow " failure. There is no 'af-stack migrate' subcommand." +yellow " docker compose up -d --force-recreate runtime" +yellow " docker compose logs runtime | grep 'migrations applied'" green "==> Restore complete. Validate with scripts/test-quickstart.sh." From d02288e7cc353908fb7f23c4bebe220deb84b847 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 6/9] docs(skill): fix the workflow step, paths, and references coding agents follow - Canonical-workflow step 4 still told agents to run `af-stack init --template coding-agent`, which the positional form rejects; it now clones and brands in place like the header. - The customer-app surface was described as an `(app)/` route group with layouts and pages that do not exist; corrected to the real `src/app/` layout, including the snippet. - Cross-references pointed at four files that do not exist, a `cost-explorer` plugin that was never in the repo, and a wrong path for the checked-in OpenAPI document. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- skills/af-stack/SKILL.md | 42 ++++--- skills/af-stack/examples/forge.md | 12 +- skills/af-stack/rules/agents.md | 11 ++ skills/af-stack/rules/boundaries.md | 28 ++--- skills/af-stack/rules/customer-app.md | 106 +++++++++++------- skills/af-stack/rules/dashboard-plugins.md | 6 +- skills/af-stack/rules/edit-surfaces.md | 4 +- skills/af-stack/rules/primitives.md | 2 +- skills/af-stack/rules/sdk.md | 5 +- skills/af-stack/rules/workload-modules.md | 16 ++- .../af-stack/snippets/customer-app-page.tsx | 23 ++-- 11 files changed, 162 insertions(+), 93 deletions(-) diff --git a/skills/af-stack/SKILL.md b/skills/af-stack/SKILL.md index abcab7ff..2880b9ae 100644 --- a/skills/af-stack/SKILL.md +++ b/skills/af-stack/SKILL.md @@ -32,9 +32,11 @@ af-stack mcp add github --transport stdio \ # register tool serv # edit the four surfaces below, then ship via deploy/ (Helm/Fly/Railway/Render/compose) ``` -`af-stack init --template coding-agent` brands the checkout and adds a real -coding agent (multi-tenancy ON, a GH_TOKEN secret slot). Everything after is -editing the four surfaces. Prefer these commands over hand-copying files. +`af-stack init --name "" --template coding-agent` brands the checkout and +adds a real coding agent (multi-tenancy ON, a GH_TOKEN secret slot). `--name` is +required: init only prompts for it on a TTY, so a non-interactive shell (what a +coding agent runs in) must pass the flag. Everything after is editing the four +surfaces. Prefer these commands over hand-copying files. `af-stack init ` with a positional name is different: it scaffolds a small standalone app that calls a running BackAI, in any directory, with no surfaces to brand or extend. @@ -45,10 +47,10 @@ If unsure of the strategic frame, primitives table, or canonical DX: - `docs/stack.md` — the 8-band layered architecture (Client / Edge / API Gateway / Intelligence / Execution / Delivery / Observability / Data) -- `development/positioning.md` — the canonical fork-and-edit DX + vocabulary - (Workload Module · Dashboard Plugin · Adapter) -- `development/strategy.md` — the ownership boundary (AgentField vs af-stack vs - LiteLLM) and the 4-phase plan +- `docs/dx/README.md` — the golden path and the four edit surfaces +- `docs/dx/adapters.md` — what an Adapter is and which slots swap by env var +- [`rules/boundaries.md`](rules/boundaries.md) — the ownership boundary + (AgentField vs af-stack vs LiteLLM) - `docs/product.md` — what's REAL vs needs-key vs not-in-v1 ## The 4 edit surfaces (the most important table) @@ -58,7 +60,7 @@ Every other directory is platform code you don't edit. | Surface | Where | Language | What goes here | |---|---|---|---| -| **Customer App** | `apps/customer-app/src/app/(app)/...` | TypeScript / React | Branded SaaS pages the customer sees (sign-up, dashboard, billing, app-specific UI) | +| **Customer App** | `apps/customer-app/src/app//page.tsx` (pattern: `src/app/dashboard/page.tsx`) | TypeScript / React | Branded SaaS pages the customer sees (dashboard, app-specific UI). Auth pages live under `(auth)/` — don't edit them | | **Agent** | `apps/backend/agents//` | Python | AgentField agent definition + reasoners + harness use + MCP server registration | | **Workload Module** | `workload-modules//` (scaffold with `af-stack module new `) | Go in-runtime handler is the intended path, but the runtime loader isn't wired yet — a Python workload runs as an AF agent today | Backend HTTP routes + DB migrations + jobs + crons that aren't core platform | | **Dashboard Plugin** | `apps/dashboard/plugins//` | TypeScript / React | Operator-console read-only tabs (charts, lists, status) | @@ -143,7 +145,9 @@ page), you don't need Tier 2. Use the dashboard, or call these from an operator-only workload-module route gated by your auth rules. **Authoritative source**: for the live REST surface, read -`/api/v1/openapi.json` on a running runtime (or `apps/backend/static/openapi.json`). +`/api/v1/openapi.json` on a running runtime. A checked-in snapshot lives at +`docs-site/public/openapi.json` (refresh with `docs-site/scripts/fetch-openapi.sh`; +it can lag the runtime). For the Python SDK, see `packages/sdk-py/af_stack/`. For the TS SDK, see `packages/sdk-ts/src/`. For the Go SDK, see `packages/sdk-go/suite/`. @@ -202,7 +206,7 @@ These are non-negotiable. Each has a detailed rationale in `rules/`. [`rules/sdk.md`](rules/sdk.md) → "LLM rate limits — 429 responses". 10. **The repo IS the product.** No "managed offering" code paths, no "free tier" feature gates in OSS. We don't ship code that depends on - a SaaS we run. See `development/positioning.md`. + a SaaS we run. See [`rules/boundaries.md`](rules/boundaries.md). ## Canonical workflow — when the user says "build X on AF Stack" @@ -219,14 +223,22 @@ Follow this sequence. Don't skip steps. 3. **Map primitives.** Walk the primitives table; mark which rows X uses. If any are 🚧 (roadmap), warn the user and propose a workaround or wait. -4. **Scaffold.** For a NEW project, start from the CLI: - `af-stack init --template `. To add a surface to - an EXISTING project, copy the matching template from `snippets/`: +4. **Scaffold.** For a NEW project, clone the repo and brand it in place — + `git clone https://github.com/Agent-Field/backai && cd `, then + `af-stack init --name "" --template `. + (`af-stack init ` with a positional name is a different command: it + scaffolds a small standalone `node`/`saas` app that calls a running BackAI + and has none of the four surfaces.) To add a surface to an EXISTING + checkout, use `af-stack agent|module|plugin new`, or copy the matching + template from `snippets/`: - New agent → `snippets/agent.py` - New workload module (Python sidecar) → `snippets/workload-module/` - New dashboard plugin → `snippets/dashboard-plugin/` - New customer-app page → `snippets/customer-app-page.tsx` - Drop into the right surface path. Rename + edit. + Drop into the right surface path. Rename + edit. Then check it offline — + `af-stack module validate ` / `af-stack agent validate ` (both + take a **directory path**, not a bare id, and accept `--json`; no runtime + needed). 5. **Wire with SDK only.** Connect surfaces using `suite.*` (runtime handlers, dashboard, customer-app) or `app.*` (inside agents). Never reach the DB / LiteLLM / AgentField directly from outside its layer. @@ -255,7 +267,7 @@ Session-scope memory; see `rules/boundaries.md`." - **AgentField (inside agents)**: `from agentfield import Agent, AIConfig` + `app.ai(...)`, `app.memory.*`, `app.harness(...)`, `@app.reasoner(...)`. - **OpenAPI (machine-readable)**: `GET /openapi.json` on a running - runtime, or `apps/backend/static/openapi.json`. + runtime, or the snapshot at `docs-site/public/openapi.json`. ## Detailed references (fetch on demand) diff --git a/skills/af-stack/examples/forge.md b/skills/af-stack/examples/forge.md index 50b46904..fab69391 100644 --- a/skills/af-stack/examples/forge.md +++ b/skills/af-stack/examples/forge.md @@ -12,7 +12,7 @@ inline comments. Sold per-developer-seat. | Surface | Path | What you write | |---|---|---| -| Customer App | `apps/customer-app/src/app/(app)/` | 3–5 pages: dashboard, repos, billing, settings | +| Customer App | `apps/customer-app/src/app//page.tsx` | 3–5 pages: dashboard, repos, billing, settings | | Agent | `apps/backend/agents/forge/` | 1 reasoner: `review_pr` | | Workload Module | `examples/forge/handlers/` | 3 routes (webhook, list reviews, stats) + 1 job + 1 migration | | Dashboard Plugin | `apps/dashboard/plugins/forge/` | 1 page: cross-tenant stats | @@ -340,7 +340,7 @@ export default async function ForgePage() { ## Surface 4 — Customer app -`apps/customer-app/src/app/(app)/dashboard/page.tsx` (customer's view of +`apps/customer-app/src/app/dashboard/page.tsx` (customer's view of their reviews): ```tsx @@ -375,8 +375,9 @@ export default async function CustomerDashboard() { } ``` -Plus a `(app)/repos/page.tsx` to connect / disconnect repos, and the -existing `(app)/billing/page.tsx` (pre-wired) for Stripe/Lago. +Plus `src/app/repos/page.tsx` to connect / disconnect repos, and a +`src/app/billing/page.tsx` you write for Stripe/Lago — there is no +pre-wired customer billing page; the operator dashboard owns billing today. **~3–5 customer-app pages, ~300 lines total.** @@ -386,7 +387,8 @@ existing `(app)/billing/page.tsx` (pre-wired) for Stripe/Lago. # Day 1 git clone github.com/yourorg/forge # their fork of AF Stack cd forge -af-stack init --name "Forge" --color "#0066FF" --logo ./forge-logo.svg +af-stack init --name "Forge" --color "#0066FF" +# optional: --logo ./forge-logo.svg sets the light+dark mark in brand.yaml # Day 2–4: write the agent + workload module + dashboard plugin # + tweak 4 customer-app pages diff --git a/skills/af-stack/rules/agents.md b/skills/af-stack/rules/agents.md index 6e2e61af..1ec09d9a 100644 --- a/skills/af-stack/rules/agents.md +++ b/skills/af-stack/rules/agents.md @@ -19,6 +19,17 @@ The agent registers with the AgentField control plane on startup. The runtime gateway forwards `POST /api/v1/agents/.` to the agent. +Scaffold one with `af-stack agent new `, then check it offline — no +Docker, no runtime, no operator key: + +```bash +af-stack agent validate apps/backend/agents/ # add --json +``` + +It takes a **directory path** (a bare agent id exits 4) and checks the +`main.py` entry point. Exit 0 = valid, 5 = validation failed, 4 = the +directory doesn't exist, 2 = bad args. + ## The Agent + reasoner pattern ```python diff --git a/skills/af-stack/rules/boundaries.md b/skills/af-stack/rules/boundaries.md index efa3a2f0..607734d8 100644 --- a/skills/af-stack/rules/boundaries.md +++ b/skills/af-stack/rules/boundaries.md @@ -24,8 +24,8 @@ unmaintainable. **Why**: AgentField IS the AI runtime. Duplicating its primitives in af-stack creates two sources of truth, drifts on schema, and confuses -which one the user should use. The platform boundary in `development/strategy.md` -is the contract. +which one the user should use. The platform boundary above is the +contract. **The correct primitive**: @@ -121,10 +121,10 @@ feature flags that toggle between "OSS edition" and "Enterprise edition." **Why**: AF Stack is Apache 2.0 and forkable. The repo the user clones IS the running product. No hosted version exists to compete with the fork. (This is the core differentiator from Supabase / Appwrite / Nhost — -see `development/positioning.md`.) +see `docs/product.md`.) **The correct primitive**: every feature is in the repo. Enterprise -controls like SSO, RBAC, BYOK, and GDPR (planned, tracked in `development/strategy.md`) ship +controls like SSO, RBAC, BYOK, and GDPR (planned) ship in-tree. Operator opts in via env / config. ## Other rules with similar weight @@ -142,10 +142,12 @@ inside an audited operator route. See `rules/multi-tenancy.md`. ### B7 — Don't write to env from the UI -The dashboard is read-only on tier-1 + tier-2 config (per -`development/operator-console-inventory.md`). If the user wants to change adapters / providers / -modules, they edit `.env` or `config.yaml` and restart. The dashboard -shows what's active; it doesn't change it. +The dashboard never rewrites `.env`. To change which adapter, provider, or +module is active, the operator edits `.env` or `config.yaml` and restarts; +the dashboard shows what's active. The one exception is adapter +*credentials* — Platform → Integrations writes those into the vault +server-side (never echoed back), so don't build a second settings UI for +them either. ### B8 — Don't add tools to runtime handlers @@ -158,8 +160,9 @@ runtime is the gateway, not the agent. `apps/customer-app/` already has: better-auth pages, dashboard layout, sign-up flow (auto-provisions tenant + membership + API key), brand -theming via CSS variables. Edit pages under `(app)/`. Don't rewrite -`(auth)/` or the layout. +theming via CSS variables. Add pages as `src/app//page.tsx` +(pattern: `src/app/dashboard/page.tsx`). Don't rewrite `(auth)/` or the +root `app/layout.tsx`. ### B10 — Don't fork the agent SDK @@ -190,6 +193,5 @@ Examples of common requests + the correct response: ## When in doubt -Read `development/positioning.md` Part 1 (the strategic frame) and `development/strategy.md` -("Ownership Boundary"). Those two are the source of truth for what -belongs where. +Read `docs/product.md` (what's real vs planned) and the ownership +boundary above. Those are the source of truth for what belongs where. diff --git a/skills/af-stack/rules/customer-app.md b/skills/af-stack/rules/customer-app.md index 630c0227..e1bc7448 100644 --- a/skills/af-stack/rules/customer-app.md +++ b/skills/af-stack/rules/customer-app.md @@ -15,39 +15,54 @@ apps/customer-app/src/ │ ├── (auth)/ # ← DON'T edit — sign-in, sign-up, sign-out │ │ ├── sign-in/ │ │ ├── sign-up/ -│ │ └── sign-out/ -│ ├── (app)/ # ← EDIT FREELY — customer-facing pages -│ │ ├── dashboard/ -│ │ ├── settings/ -│ │ ├── billing/ # ← billing page (wired to suite.billing) -│ │ ├── api-key/ # ← customer's API key management -│ │ ├── code-helper/ # ← demo page, replace with your product -│ │ └── ... your pages here -│ ├── api/ # ← MOSTLY don't edit; some proxy routes here -│ ├── layout.tsx # ← brand-only edits +│ │ ├── sign-out/ +│ │ └── layout.tsx +│ ├── api/ # ← MOSTLY don't edit — better-auth handler, +│ │ ├── auth/[...all]/ # onboarding key, and the runtime proxy +│ │ ├── customer/onboarding-key/ +│ │ └── v1/[...path]/ # ← proxy to the runtime (/api/v1/...) +│ ├── dashboard/ # ← the one shipped customer page; copy it +│ │ └── page.tsx +│ ├── / # ← EDIT FREELY — add your pages here +│ ├── brand.css # ← generated from brand.yaml; don't hand-edit +│ ├── favicon.ico # ← your favicon lives HERE (no public/ dir) +│ ├── globals.css +│ ├── layout.tsx # ← root layout; brand-only edits │ └── page.tsx # ← landing page; edit freely ├── components/ │ ├── ui/ # ← DON'T edit — shadcn primitives -│ ├── layout/ # ← brand-only edits (header / sidebar) +│ ├── app-sidebar.tsx # ← the sidebar; nav is an inline items array +│ ├── nav-*.tsx # ← sidebar sections │ └── ... your components -└── lib/ - └── ... # auth-client, utils +├── hooks/ +├── lib/ # api, auth, auth-client, brand (generated), +│ └── ... # db, provisioning, session, sso, utils +└── middleware.ts # ← the auth gate ``` +There is **no `(app)/` route group**, no `components/layout/`, and no +`public/` directory. Routes are plain folders under `src/app/`; creating +`src/app/(app)/dashboard/page.tsx` alongside the real `src/app/dashboard/` +is a hard Next.js duplicate-route build failure. + ## Edit zones — the contract | Zone | Edit policy | What lives there | |---|---|---| | `(auth)/` | **Don't edit** | Pre-wired better-auth flows (sign-up auto-provisions tenant + membership + API key) | -| `(app)/` | **Edit freely** | Your customer-facing pages | +| `app//` | **Edit freely** | Your customer-facing pages | | `api/` | **Mostly don't edit** | Proxy routes to the runtime; better-auth handlers | | `components/ui/` | **Don't edit** | shadcn primitives — extend if needed, don't modify | -| `components/layout/` | **Brand only** | Sidebar / topbar — edit the brand bits, keep the auth shell | -| `app/layout.tsx` | **Brand only** | Wraps everything; brand colors / fonts | +| `components/app-sidebar.tsx` | **Brand only** | Sidebar shell + the inline nav `items` array | +| `app/layout.tsx` | **Brand only** | Root layout; brand colors / fonts | | `app/page.tsx` | **Edit freely** | Landing page | -When you add a page, drop it under `(app)//page.tsx`. The -auth middleware enforces sign-in before serving anything under `(app)/`. +When you add a page, drop it at `src/app//page.tsx`. Gating is +**deny-by-default**: `src/middleware.ts` matches every route and redirects +to `/sign-in` unless the path starts with one of the `PUBLIC_PREFIXES` +(`/sign-in`, `/sign-up`, `/api/`, `/_next`, `/favicon`) — so a new page is +signed-in-only automatically, without belonging to any route group. In +personal mode (`AF_STACK_MODE=personal`) the gate is skipped entirely. ## What's pre-wired (don't reinvent) @@ -71,29 +86,33 @@ Don't pass tenant IDs around. ### Layout shell -`(app)/layout.tsx` wraps every customer page with the sidebar + topbar. -You're free to edit the brand bits (logo, name, color); leave the auth + +There is no shared app shell layout — each page composes its own by +mounting `` + ``. Copy +`src/app/dashboard/page.tsx` as the pattern. You're free to edit the brand +bits (logo, name, color) in `components/app-sidebar.tsx`; leave the auth + session machinery alone. ### Brand theming -CSS variables in `apps/customer-app/src/app/brand.css` (will eventually -move this to a generated artifact from `brand.yaml`). Every shadcn -primitive, every chart, every page inherits the palette. +CSS variables in `apps/customer-app/src/app/brand.css` — generated from +`brand.yaml` by `scripts/generate-brand.mjs`, so edit `brand.yaml`, not the +CSS. Every shadcn primitive, every chart, every page inherits the palette. Don't hardcode hex colors. Use Tailwind tokens (`bg-primary`, `text-foreground`, `border-border`, `text-muted-foreground`). -### Billing UI - -`(app)/billing/page.tsx` is wired to `suite.billing.*`. When the -operator has Stripe / Lago configured, real portal links work; when -not, an empty state shows. +### Billing and API keys — NOT shipped as customer pages -### API key management +There is no customer-facing billing page and no customer-facing API-key +page. The **operator dashboard** owns both today +(`apps/dashboard/src/app/(dashboard)/platform/billing` and +`.../people/keys`). -`(app)/api-key/page.tsx` lets the customer view + rotate their tenant -API key. Pre-wired; don't reinvent. +To add a customer-facing one, create +`apps/customer-app/src/app/billing/page.tsx` and call the runtime through +the app's own proxy route (`src/app/api/v1/[...path]/route.ts`) — the +`@af-stack/sdk` package is **not** a dependency of `apps/customer-app`, so +don't `import { suite } from "@af-stack/sdk"` here. ## How to use suite.* from customer pages @@ -128,26 +147,30 @@ runtime. To take a fresh fork from BackAI to your product: 1. **`brand.yaml`** — the source of truth for app name, colors, and logo. + The product name reaches components through the generated + `src/lib/brand.ts` (`brand.displayName`); don't hardcode it. 2. **`apps/customer-app/src/app/page.tsx`** — landing page copy. -3. **`apps/customer-app/src/components/layout/customer-topbar.tsx`** — - logo + product name in the topbar. -4. **`apps/customer-app/public/favicon.ico`** + logo files — your assets. -5. **`apps/customer-app/src/app/(app)/dashboard/page.tsx`** — the first - thing customers see after sign-in. +3. **`apps/customer-app/src/components/app-sidebar.tsx`** — the sidebar + nav: the inline `items` array passed to ``. +4. **`apps/customer-app/src/app/favicon.ico`** — your favicon (there is no + `public/` directory). +5. **`apps/customer-app/src/app/dashboard/page.tsx`** — the first thing + customers see after sign-in. -The CLI should eventually ship `af-stack init`, which does steps 1, 3, and 4 -automatically. +`af-stack init --name "" --color "<#hex>"` does step 1 for you (and +`--logo ` sets the light+dark mark in `brand.yaml`); regenerate the +derived files with `pnpm run generate:brand`. ## Adding a page — concrete walkthrough The user wants `/items` as a customer-facing list: -1. Create `apps/customer-app/src/app/(app)/items/page.tsx`. Copy from +1. Create `apps/customer-app/src/app/items/page.tsx`. Copy from `snippets/customer-app-page.tsx`. 2. Edit the title, description, and `fetchItems()` to point at your workload module's `/items` route. -3. Add `Items` to the sidebar in - `apps/customer-app/src/components/layout/customer-sidebar.tsx`. +3. Add `Items` to the inline `items` array in + `apps/customer-app/src/components/app-sidebar.tsx`. 4. Test: `docker compose up`, sign up, click "Items" in the sidebar. ## What you should NOT do in the customer-app @@ -158,6 +181,7 @@ The user wants `/items` as a customer-facing list: | Read the DB directly | Customer app shouldn't know your schema | Route through your workload module | | Add a backend route in `app/api/...` for business logic | API routes here are proxies, not domain logic | Add to your workload module | | Modify `(auth)/` | Breaks the sign-up auto-provisioning | Override theme via brand only | +| Create `src/app/(app)/...` | The route group doesn't exist; duplicates a real route and fails the Next.js build | Plain folder: `src/app//page.tsx` | | Use hex colors | Breaks dark mode + theming | Tailwind tokens | | Pass tenant_id around manually | RLS handles it | Trust the session | | Build a chat history UI without using AgentField Session memory | Drift from the platform | Call your agent which uses Session-scope memory | diff --git a/skills/af-stack/rules/dashboard-plugins.md b/skills/af-stack/rules/dashboard-plugins.md index 56cf820d..a7f4343d 100644 --- a/skills/af-stack/rules/dashboard-plugins.md +++ b/skills/af-stack/rules/dashboard-plugins.md @@ -41,7 +41,7 @@ export default definePlugin({ | Group | When | Examples | |---|---|---| | `build` | Your product config: agents, modules, integrations | Future: a "module config" view | -| `operate` | Live runtime state: charts, status, lists | The first-party `cost-explorer` plugin sits here | +| `operate` | Live runtime state: charts, status, lists | Cost/usage charts, run status, queue depth | | `customers` | Per-tenant / per-end-user views | `notable` example plugin (per-tenant note counts) | If unsure: `operate` is the default for monitoring views. @@ -101,8 +101,8 @@ is server-rendered each request so the operator sees fresh state. ## Charts -`recharts` is already a dependency. The cost-explorer plugin -(`apps/dashboard/plugins/cost-explorer/`) shows the pattern. +`recharts` is already a dependency (`apps/dashboard/package.json`). +Charts must be client components: ```tsx "use client" // recharts needs the client diff --git a/skills/af-stack/rules/edit-surfaces.md b/skills/af-stack/rules/edit-surfaces.md index a9ac19cc..ce31ae63 100644 --- a/skills/af-stack/rules/edit-surfaces.md +++ b/skills/af-stack/rules/edit-surfaces.md @@ -7,7 +7,7 @@ The 4 edit surfaces in the user's fork. This file resolves the | Surface | Path | Language | |---|---|---| -| Customer App | `apps/customer-app/src/app/(app)/...` | TypeScript / React | +| Customer App | `apps/customer-app/src/app//page.tsx` | TypeScript / React | | Agent | `apps/backend/agents//` | Python | | Workload Module | `examples//handlers/` (Python sidecar today) OR `services/runtime/internal/modules//` (Go, eventually) | Python (sidecar) or Go (in-runtime) | | Dashboard Plugin | `apps/dashboard/plugins//` | TypeScript / React | @@ -19,7 +19,7 @@ The user wants ... ├─ a page in the customer-facing SaaS? │ → Customer App -│ apps/customer-app/src/app/(app)//page.tsx +│ apps/customer-app/src/app//page.tsx │ ├─ a tab in the operator console showing some state? │ → Dashboard Plugin diff --git a/skills/af-stack/rules/primitives.md b/skills/af-stack/rules/primitives.md index ad648733..1c6991cd 100644 --- a/skills/af-stack/rules/primitives.md +++ b/skills/af-stack/rules/primitives.md @@ -258,7 +258,7 @@ Per-tenant budgets returning `HTTP 402 BUDGET_EXCEEDED` when crossed. ## Roadmap primitives (yet to ship) -These are documented in `docs/extensibility.md` / `development/strategy.md`. If the user +`docs/product.md` tracks what's REAL vs planned. If the user needs them today, propose a workaround or wait. | Primitive | Status | Workaround until shipped | diff --git a/skills/af-stack/rules/sdk.md b/skills/af-stack/rules/sdk.md index 1941d392..4d3a55f1 100644 --- a/skills/af-stack/rules/sdk.md +++ b/skills/af-stack/rules/sdk.md @@ -144,8 +144,9 @@ When you need a specific call: `packages/sdk-py/af_stack/.py` — these files have Sphinx docstrings that map every call to its REST endpoint. 2. **OpenAPI**: `GET /openapi.json` on a running runtime. Live truth. -3. **Existing code**: search how the cost-explorer plugin / Notable - example / sample agent use the SDK — those are tested patterns. +3. **Existing code**: search how `examples/01-notable/dashboard-plugin/`, + `examples/starter/dashboard-plugin/`, and the sample agent use the + SDK — those are tested patterns. ## LLM rate limits — 429 responses diff --git a/skills/af-stack/rules/workload-modules.md b/skills/af-stack/rules/workload-modules.md index a2915b6c..7c2bf59c 100644 --- a/skills/af-stack/rules/workload-modules.md +++ b/skills/af-stack/rules/workload-modules.md @@ -208,6 +208,18 @@ const stats = await fetch(`${process.env.RUNTIME_URL}/workload//stats`) ## Testing locally +Validate the module offline first — no Docker, no runtime, no operator key: + +```bash +af-stack module validate workload-modules/ # add --json for machine output +``` + +It checks the manifest shape and lints the `migrations/` SQL for tenant +isolation. Exit 0 = valid, 5 = validation failed, 4 = the directory doesn't +exist, 2 = bad args. It takes a **directory path** — a bare module id exits 4. + +Then bring the stack up: + ```bash # Start base AF Stack docker compose up -d @@ -243,5 +255,5 @@ services/runtime/internal/modules// See `services/runtime/internal/modules/modules.go` for the `Module` interface contract. Registration happens in `services/runtime/cmd/af-stack/main.go`. Don't add Go modules without -reading `development/strategy.md` first — eventually is when this becomes the canonical -shape. +reading [`boundaries.md`](boundaries.md) first — eventually is when this +becomes the canonical shape. diff --git a/skills/af-stack/snippets/customer-app-page.tsx b/skills/af-stack/snippets/customer-app-page.tsx index 6af0cbfd..3ff032cd 100644 --- a/skills/af-stack/snippets/customer-app-page.tsx +++ b/skills/af-stack/snippets/customer-app-page.tsx @@ -1,27 +1,32 @@ // Template: customer-app page (Next.js App Router). // -// Drop into apps/customer-app/src/app/(app)//page.tsx. -// Pages under (app)/ require the customer to be signed in — better-auth -// middleware enforces this. +// Drop into apps/customer-app/src/app//page.tsx (plain folder +// under src/app/ — there is no (app)/ route group). Every route is +// signed-in-only by default: src/middleware.ts is deny-by-default with a +// PUBLIC_PREFIXES allowlist (/sign-in, /sign-up, /api/, /_next, /favicon). // // You GET for free (don't reinvent): // - better-auth sign-up / sign-in (already wired; pages under (auth)/). // - On sign-up, a tenant + membership + API key are auto-provisioned // for the user. Their tenant_id is bound on every request to this // page. -// - The customer-app layout shell (sidebar, header, theme). +// - The sidebar shell — mount + yourself; +// copy src/app/dashboard/page.tsx as the pattern (there is no shared +// (app)/layout.tsx). // - shadcn/ui components in @/components/ui/* and lucide-react icons. -// - The @af-stack/sdk suite SDK for typed runtime calls. +// - The app's own runtime proxy at src/app/api/v1/[...path]/route.ts. +// (@af-stack/sdk is NOT a dependency of apps/customer-app — call the +// runtime over that proxy, as fetchItems() below does.) // // What you WRITE: -// - Server-side data fetch via suite.* SDK helpers. +// - Server-side data fetch through the /api/v1 proxy. // - The JSX. Match brand.yaml (Phase 1) / brand.css for theme. // // REMEMBER: // - Don't talk to the database directly. Always go through a workload -// module (your code) or a suite.* SDK call. -// - Don't reach a model provider. Always go through suite.llm.* or an -// agent call. +// module (your code) or the runtime's REST surface. +// - Don't reach a model provider. Always go through the LLM gateway at +// /api/v1/llm/* or an agent call. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" From fa7ffc754c2a9b229b8d7e59e6b30716d8c4cdb7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 7/9] docs(site): drop the cost-explorer plugin reference that never existed Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- docs-site/src/content/docs/guides/customize-dashboard.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/customize-dashboard.md b/docs-site/src/content/docs/guides/customize-dashboard.md index c1a81f0d..3c5ad360 100644 --- a/docs-site/src/content/docs/guides/customize-dashboard.md +++ b/docs-site/src/content/docs/guides/customize-dashboard.md @@ -95,7 +95,10 @@ discovers the manifest, generates `apps/dashboard/src/lib/plugins.generated.ts`, and the sidebar nav adds your tab under its declared group. -Working reference: [`apps/dashboard/plugins/cost-explorer/`](https://github.com/Agent-Field/backai/tree/main/apps/dashboard/plugins/cost-explorer). +Working reference: [`examples/01-notable/dashboard-plugin/`](https://github.com/Agent-Field/backai/tree/main/examples/01-notable/dashboard-plugin) +— a real `plugin.ts` + `page.tsx` pair you can copy into +`apps/dashboard/plugins//`. No plugins ship enabled by default; the +directory is created by `af-stack plugin new `. Full guide: [Reference → Dashboard Plugins](/reference/dashboard-plugins/). ## What you DON'T need to fork for From 9f5990d6fb67220dd71e16dd2f647ea0f492d8ac Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:45:09 -0400 Subject: [PATCH 8/9] fix(cli): make help and usage text match what the commands accept - `af-stack init --help` documented only the in-checkout form and advertised `--template coding-agent` for the positional form, which rejects it; both forms are now described where each applies. - The usage example `af-stack init --template coding-agent` fails non-interactively because --name is required; the example now passes it. - `agent validate`, `module validate`, and `adapter new` ship but were absent from help; `--no-open`'s flag help described the opposite of its behaviour; the generated saas app's next steps named a command that does not exist. - AGENTS.md joins the files whose `.echo` literal `af-stack init --name` rewrites, so the proof-of-wiring curl follows the branded node id. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- services/cli/cmd/af-stack/main.go | 24 +++++++----- services/cli/internal/initcmd/init.go | 7 ++-- services/cli/internal/initcmd/init_test.go | 7 ++++ services/cli/internal/initcmd/scaffold.go | 2 +- .../cli/internal/initcmd/scaffold_saas.go | 8 ++-- .../internal/initcmd/scaffold_saas_test.go | 29 ++++++++++++++ .../cli/internal/initcmd/scaffold_test.go | 38 +++++++++++++++++++ services/cli/internal/project/project.go | 2 +- services/cli/internal/project/project_test.go | 16 ++++++++ 9 files changed, 115 insertions(+), 18 deletions(-) diff --git a/services/cli/cmd/af-stack/main.go b/services/cli/cmd/af-stack/main.go index d5de8370..2e9d52d3 100644 --- a/services/cli/cmd/af-stack/main.go +++ b/services/cli/cmd/af-stack/main.go @@ -14,7 +14,10 @@ // af-stack agent new Scaffold an AgentField agent // af-stack module new Scaffold a workload module // af-stack plugin new Scaffold a dashboard plugin -// af-stack adapter list Show active adapter choices +// af-stack agent validate Validate an agent scaffold (offline, --json) +// af-stack module validate Validate a workload module (offline, --json) +// af-stack adapter new [name] Scaffold a remote-adapter sidecar +// af-stack adapter list Show active adapter choices (operator) // af-stack deploy Deploy via helm/fly/railway/render // af-stack operator create --email Allow an operator // af-stack operator key [--owner] Mint an operator API key (direct DB) @@ -267,13 +270,13 @@ Commands: upgrade Pull the latest upstream AF Stack into this fork (--check for a dry run) dev Start docker compose for local development mode Switch personal (auth+billing off) ⇄ saas (af-stack mode [personal|saas]) - agent Agent scaffold commands - module Workload module scaffold commands + agent Agent commands: new | validate + module Workload module commands: new | validate plugin Dashboard plugin scaffold commands - adapter Adapter discovery commands + adapter Adapters: new scaffolds a remote sidecar (list is operator-only) deploy Deploy wrappers for helm/fly/railway/render operator Operator bootstrap commands (create, key) - mcp Model Context Protocol server + tool management + mcp Model Context Protocol servers + tools (needs a running runtime) billing Set up Stripe billing: plans + pricing (agent-first) job Scaffold a background-worker job (af-stack job new --lang py|ts) connection External-service connections: add | list | remove @@ -284,8 +287,9 @@ Commands: test Shippable-fork gates (manifests, migrations, ...) version Print the CLI version -Operator commands (need AF_STACK_API_KEY = operator key; mint one with -`+"`af-stack operator key`"+`): +Operator commands (need a running runtime + AF_STACK_API_KEY = operator key; +mint one with `+"`af-stack operator key`"+`): + adapter Active adapter choices: adapter list keys API keys: list | issue | rotate | revoke | spend agents Registered agents: list reasoners Per-reasoner cost/latency/error analytics @@ -299,7 +303,7 @@ Operator commands (need AF_STACK_API_KEY = operator key; mint one with Examples: af-stack init my-app # scaffold a new project that consumes the stack - af-stack init --template coding-agent # in-checkout: rebrand + scaffold the hero coding agent + af-stack init --name "Acme Coder" --template coding-agent # in-checkout: rebrand + hero coding agent af-stack init --name "DocuChat" --color "#0A66C2" # in-checkout: re-theme this fork af-stack upgrade --check # what would an upgrade bring? (commits, migrations, conflicts) af-stack upgrade # backup DB, merge upstream, print rebuild steps @@ -307,11 +311,13 @@ Examples: af-stack mode personal # single-user app: no login, no billing af-stack mode saas # back to multi-tenant SaaS af-stack agent new researcher - af-stack adapter list + af-stack adapter new storage my-s3 # scaffold a remote-adapter sidecar (no checkout needed) + af-stack module validate workload-modules/notes # offline manifest + RLS gate (--json for CI) af-stack deploy helm af-stack operator create --email founder@example.com af-stack operator key --owner # mint an operator API key (needs DATABASE_URL) af-stack keys issue --tenant --name ci + af-stack adapter list # active adapters (needs a running runtime + operator key) af-stack job new resize-image --lang py # scaffold jobs/resize-image.py af-stack connection add --provider github --kind api_key --name ci echo -n "$STRIPE_KEY" | af-stack secrets set stripe --value-stdin diff --git a/services/cli/internal/initcmd/init.go b/services/cli/internal/initcmd/init.go index 238f2774..361ad85c 100644 --- a/services/cli/internal/initcmd/init.go +++ b/services/cli/internal/initcmd/init.go @@ -89,13 +89,13 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return runScaffold(args, stdout, stderr) } - fs := flag.NewFlagSet("af-stack init", flag.ContinueOnError) + fs := flag.NewFlagSet("af-stack init (in-checkout re-theme; for a new standalone app use: af-stack init )", flag.ContinueOnError) fs.SetOutput(stderr) name := fs.String("name", "", "project display name, e.g. DocuChat") color := fs.String("color", "", "primary brand color as #RRGGBB") - logo := fs.String("logo", "", "logo file to copy into both app public directories") + logo := fs.String("logo", "", "logo file to copy to brand/logo. and set as the light+dark mark in brand.yaml") template := fs.String("template", TemplateNode, - "scaffold template: node (rebrand only) | coding-agent (rebrand + a real coding agent)") + "scaffold template: node (rebrand only) | coding-agent (rebrand + a real coding agent) — flag-form only; requires a BackAI checkout") if err := fs.Parse(args); err != nil { return err } @@ -341,6 +341,7 @@ func updateDefaultAgentName(root, next string) error { } targets := []string{ "docker-compose.yml", + "AGENTS.md", "apps/backend/agents/sample/Dockerfile", "apps/backend/agents/sample/main.py", "apps/backend/agents/sample/README.md", diff --git a/services/cli/internal/initcmd/init_test.go b/services/cli/internal/initcmd/init_test.go index 47a201a5..0a2d245e 100644 --- a/services/cli/internal/initcmd/init_test.go +++ b/services/cli/internal/initcmd/init_test.go @@ -50,6 +50,7 @@ surfaces: `) write(t, root, "apps/backend/agents/sample/README.md", "curl /agents/sample.echo\n") write(t, root, "apps/backend/litellm-config.yaml", "# sample-agent points at this by default.\n") + write(t, root, "AGENTS.md", "curl -X POST http://localhost:8080/api/v1/agents/sample.echo\n") logo := filepath.Join(root, "source-logo.png") if err := os.WriteFile(logo, []byte("png"), 0o644); err != nil { t.Fatal(err) @@ -108,6 +109,12 @@ surfaces: if got := read(t, root, "apps/backend/agents/sample/main.py"); !strings.Contains(got, `os.getenv("NODE_ID", "docuchat")`) { t.Fatalf("agent source not updated:\n%s", got) } + // AGENTS.md carries the no-key "prove the wiring" curl, so the rebrand has + // to rewrite the reasoner path there too — otherwise the front door hands + // every reader a node id their fork no longer registers. + if got := read(t, root, "AGENTS.md"); !strings.Contains(got, "docuchat.echo") { + t.Fatalf("AGENTS.md proof curl not updated:\n%s", got) + } if !strings.Contains(stdout.String(), "default agent node_id set to docuchat") { t.Fatalf("unexpected stdout:\n%s", stdout.String()) } diff --git a/services/cli/internal/initcmd/scaffold.go b/services/cli/internal/initcmd/scaffold.go index e38859a7..8f0a38f9 100644 --- a/services/cli/internal/initcmd/scaffold.go +++ b/services/cli/internal/initcmd/scaffold.go @@ -32,7 +32,7 @@ func runScaffold(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("af-stack init ", flag.ContinueOnError) fs.SetOutput(stderr) dir := fs.String("dir", ".", "parent directory to create the project in") - template := fs.String("template", "node", "starter template: node | saas") + template := fs.String("template", "node", "starter template: node | saas (the coding-agent template is checkout-only: run af-stack init --template coding-agent inside a BackAI checkout)") force := fs.Bool("force", false, "scaffold into an existing non-empty directory") asJSON := fs.Bool("json", false, "emit the created file list as JSON") if err := fs.Parse(args); err != nil { diff --git a/services/cli/internal/initcmd/scaffold_saas.go b/services/cli/internal/initcmd/scaffold_saas.go index 5692cbf6..b3a8b0fa 100644 --- a/services/cli/internal/initcmd/scaffold_saas.go +++ b/services/cli/internal/initcmd/scaffold_saas.go @@ -594,7 +594,7 @@ Per-tenant notes for the customer app. ` + "`tenant_id`" + ` + FORCE row level security (plain SQL, forward-only — module migrations are not goose files). -Validate it offline: ` + "`af-stack module validate notes`" + `. +Validate it offline: ` + "`af-stack module validate modules/notes`" + `. ` const saasAgentMain = `"""notes-assistant — summarize + tag agent for the notes module.""" @@ -734,7 +734,7 @@ func saasCapabilitiesJSON(displayName, slug string) string { "agents": [ { "node_id": "notes-assistant", "reasoners": ["echo", "summarize"], "path": "agents/notes-assistant" } ], - "validate": { "module": "af-stack module validate notes", "agent": "af-stack agent validate notes-assistant", "all": "af-stack test" } + "validate": { "module": "af-stack module validate modules/notes", "agent": "af-stack agent validate agents/notes-assistant", "all": "af-stack test" } } ` } @@ -774,8 +774,8 @@ npm run typecheck # tsc --noEmit ` + f + `sh af-stack test # all gates (manifests, migrations, typecheck, sdk smoke) -af-stack module validate notes # just the notes module -af-stack agent validate notes-assistant +af-stack module validate modules/notes # just the notes module +af-stack agent validate agents/notes-assistant ` + f + ` See ` + "`AGENTS.md`" + ` / ` + "`CLAUDE.md`" + ` for how to build on this scaffold. diff --git a/services/cli/internal/initcmd/scaffold_saas_test.go b/services/cli/internal/initcmd/scaffold_saas_test.go index 2d34f3e1..bccd850d 100644 --- a/services/cli/internal/initcmd/scaffold_saas_test.go +++ b/services/cli/internal/initcmd/scaffold_saas_test.go @@ -168,3 +168,32 @@ func TestScaffoldSaaS_ClientTypechecks(t *testing.T) { t.Fatalf("scaffold API client failed tsc:\n%s", out) } } + +// TestScaffoldSaaS_ValidateCommandsUsePathForm pins that every generated +// mention of `af-stack {module,agent} validate` names a directory. The bare +// id form (`af-stack module validate notes`) exits 4 - the scaffold used to +// ship it in three places. +func TestScaffoldSaaS_ValidateCommandsUsePathForm(t *testing.T) { + files := SaaSTemplateFiles("Notes App", "notes-app") + for rel, contents := range files { + for _, bare := range []string{ + "af-stack module validate notes", + "af-stack agent validate notes-assistant", + } { + if strings.Contains(contents, bare) { + t.Fatalf("%s ships the bare-id validate form %q (exits 4); use the path form", rel, bare) + } + } + } + for _, tc := range []struct{ rel, want string }{ + {"README.md", "af-stack module validate modules/notes"}, + {"README.md", "af-stack agent validate agents/notes-assistant"}, + {"modules/notes/README.md", "af-stack module validate modules/notes"}, + {"capabilities.json", "af-stack module validate modules/notes"}, + {"capabilities.json", "af-stack agent validate agents/notes-assistant"}, + } { + if !strings.Contains(files[tc.rel], tc.want) { + t.Fatalf("%s missing %q:\n%s", tc.rel, tc.want, files[tc.rel]) + } + } +} diff --git a/services/cli/internal/initcmd/scaffold_test.go b/services/cli/internal/initcmd/scaffold_test.go index 3d8c2d27..d6632fda 100644 --- a/services/cli/internal/initcmd/scaffold_test.go +++ b/services/cli/internal/initcmd/scaffold_test.go @@ -5,6 +5,8 @@ package initcmd import ( "bytes" "encoding/json" + "errors" + "flag" "os" "path/filepath" "strings" @@ -111,3 +113,39 @@ func TestScaffold_UnknownTemplate(t *testing.T) { t.Fatalf("unknown-template error should mention the in-checkout coding-agent path, got %v", err) } } + +// TestInitHelpNamesBothModes pins the cross-references between init's two +// flag sets. `af-stack init` (flags only) re-themes a checkout; `af-stack +// init ` scaffolds a standalone app. Neither used to mention the +// other, and the flag form advertised --template coding-agent to readers +// standing outside a checkout, where the positional form rejects it. +func TestInitHelpNamesBothModes(t *testing.T) { + var stderr bytes.Buffer + if err := Run([]string{"-h"}, strings.NewReader(""), &bytes.Buffer{}, &stderr); !errors.Is(err, flag.ErrHelp) { + t.Fatalf("Run(-h) error = %v, want flag.ErrHelp", err) + } + for _, want := range []string{ + "in-checkout re-theme", + "af-stack init ", + "flag-form only; requires a BackAI checkout", + "brand/logo.", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("init --help missing %q:\n%s", want, stderr.String()) + } + } + + stderr.Reset() + if err := Run([]string{"acme", "-h"}, strings.NewReader(""), &bytes.Buffer{}, &stderr); err == nil { + t.Fatal("Run( -h) should not succeed") + } + for _, want := range []string{ + "af-stack init ", + "node | saas", + "checkout-only", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("init --help missing %q:\n%s", want, stderr.String()) + } + } +} diff --git a/services/cli/internal/project/project.go b/services/cli/internal/project/project.go index 231aefb7..cf6fdd5f 100644 --- a/services/cli/internal/project/project.go +++ b/services/cli/internal/project/project.go @@ -45,7 +45,7 @@ func RunDev(ctx context.Context, args []string, stdout, stderr io.Writer) error fs := flag.NewFlagSet("af-stack dev", flag.ContinueOnError) fs.SetOutput(stderr) detach := fs.Bool("detach", false, "run docker compose in detached mode") - noOpen := fs.Bool("no-open", false, "do not open the dashboard URL") + noOpen := fs.Bool("no-open", false, "with --detach, do not open the customer app URL in a browser") noPreflight := fs.Bool("no-preflight", false, "skip the port preflight/auto-allocation step") if err := fs.Parse(args); err != nil { return err diff --git a/services/cli/internal/project/project_test.go b/services/cli/internal/project/project_test.go index b13cb042..9cd891a4 100644 --- a/services/cli/internal/project/project_test.go +++ b/services/cli/internal/project/project_test.go @@ -5,6 +5,8 @@ package project import ( "bytes" "context" + "errors" + "flag" "io" "net/http" "net/http/httptest" @@ -464,3 +466,17 @@ func TestRunDevMissingPreflightScriptIsNonFatal(t *testing.T) { t.Fatal("docker compose was not run when preflight script was absent") } } + +// TestRunDevNoOpenHelpDescribesRealBehavior pins the --no-open help string. +// RunDev only opens a browser under --detach, and the surface it opens is +// the customer app - the flag used to advertise "the dashboard URL". +func TestRunDevNoOpenHelpDescribesRealBehavior(t *testing.T) { + var stderr bytes.Buffer + if err := RunDev(context.Background(), []string{"-h"}, &bytes.Buffer{}, &stderr); !errors.Is(err, flag.ErrHelp) { + t.Fatalf("RunDev(-h) error = %v, want flag.ErrHelp", err) + } + want := "with --detach, do not open the customer app URL in a browser" + if !strings.Contains(stderr.String(), want) { + t.Fatalf("dev --help missing %q:\n%s", want, stderr.String()) + } +} From 27ac9eee22bae93f3506015f4687057a23b0b649 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:56:57 -0400 Subject: [PATCH 9/9] docs: corrections from independent verification of the sweep Each of the 29 fixes was re-checked by a separate verifier against the final tree and the binary. Ten came back with a leftover or an overstatement in the new text; this commit takes them: - README told readers to change the seeded operator password "from the console"; the console has no such page. Say how the seed actually works instead, and fix .env.example's wrong port and same claim. - run.md quoted the pre-#216 checkout error text. - architecture.md still showed manifest.yaml/handler.go and a jobs/crons field for workload modules; EDITING.md still named the nonexistent (app)/ route group and sidebar file. - The cost-explorer phantom survived in the docs-site reference page and as dead code in scripts/capture-screenshots.mjs; rules/sdk.md pointed at example plugins as SDK usage when they use plain fetch. - product.md claimed a harness dashboard page that does not exist. - The restore runbook and restore.sh said the runtime exits non-zero on any failed migration; only core migrations are fatal, module and jobs failures are logged and disable that piece, so grep for both. - adapters.md omitted that personal mode needs no operator key; SKILL.md claimed init prompts only on a TTY (it always prompts on stdin). - scripts/test-quickstart.sh hardcoded supportdesk.echo, which breaks on a branded fork; it now reads the node id from compose. The SDK conformance scripts note the same assumption. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .env.example | 8 ++++---- README.md | 6 ++++-- apps/customer-app/EDITING.md | 6 ++++-- .../src/content/docs/reference/backup-restore.md | 7 +++++-- .../src/content/docs/reference/dashboard-plugins.md | 8 ++++---- docs/architecture.md | 11 ++++++----- docs/backup-restore.md | 7 +++++-- docs/dx/adapters.md | 3 ++- docs/dx/run.md | 4 +++- docs/product.md | 2 +- scripts/capture-screenshots.mjs | 10 ---------- scripts/restore.sh | 7 ++++--- scripts/sdk-conformance/run.py | 1 + scripts/sdk-conformance/run.ts | 1 + scripts/test-quickstart.sh | 13 +++++++++---- skills/af-stack/SKILL.md | 5 +++-- skills/af-stack/rules/sdk.md | 6 +++--- 17 files changed, 59 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index dff49108..00edc9c8 100644 --- a/.env.example +++ b/.env.example @@ -258,10 +258,10 @@ AF_STACK_S3_REGION=us-east-1 # ---------- Optional integrations ---------- # ---------- Default operator account ---------- -# Seeded on first boot so the operator console at http://localhost:3000 is -# usable immediately — no signup wizard. Log in with these, then CHANGE THE -# PASSWORD from the console. Seeding only runs while the operator table is -# empty, so changing these values after first boot has no effect (reset the +# Seeded on first boot so the operator console at http://localhost:33000 is +# usable immediately — no signup wizard (the console has no change-password +# page yet, so pick real values here). Seeding only runs while the operator +# table is empty, so changing these values after first boot has no effect (reset the # Postgres volume to re-seed). Set AF_STACK_DEFAULT_OPERATOR_DISABLED=true to # skip seeding entirely (e.g. when you provision operators another way). # AF_STACK_DEFAULT_OPERATOR_EMAIL=operator@af-stack.local diff --git a/README.md b/README.md index 7d819d52..abbaba0b 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ did in the operator console at `http://localhost:33000` — sign in with `operator@af-stack.local` / `changeme123`. Set `AF_STACK_DEFAULT_OPERATOR_EMAIL` / `AF_STACK_DEFAULT_OPERATOR_PASSWORD` in `.env` _before_ the first boot to seed different credentials; the seed only -runs while no operator exists, so change the password from the console -afterwards. `af-stack mode personal` turns the login off entirely. +runs while no operator exists; to change the seeded credentials later, reset +the Postgres volume, or set `AF_STACK_DEFAULT_OPERATOR_DISABLED=true` and +provision operators yourself. `af-stack mode personal` turns the login off +entirely. No model key is required. The first run uses a deterministic demo provider but still exercises the real gateway, tenant context, cost ledger, customer app, diff --git a/apps/customer-app/EDITING.md b/apps/customer-app/EDITING.md index b5736357..4056c36b 100644 --- a/apps/customer-app/EDITING.md +++ b/apps/customer-app/EDITING.md @@ -7,10 +7,12 @@ mostly yours, with a few platform-owned edges. These are the normal product areas: -- `src/app/(app)/*` pages and nested routes +- `src/app//page.tsx` pages and nested routes (pattern: + `src/app/dashboard/page.tsx`; auth pages under `(auth)/` are off-limits) - `src/components/*` product components - `src/lib/api.ts` client helpers for customer-visible runtime calls -- sidebar links in `src/components/layout/customer-sidebar.tsx` +- sidebar links in `src/components/app-sidebar.tsx` (the inline `items` array + passed to ``) Start from `examples/starter/customer-app/first-action/page.tsx` when adding the first logged-in workflow. diff --git a/docs-site/src/content/docs/reference/backup-restore.md b/docs-site/src/content/docs/reference/backup-restore.md index e87dad42..e0de4ed4 100644 --- a/docs-site/src/content/docs/reference/backup-restore.md +++ b/docs-site/src/content/docs/reference/backup-restore.md @@ -106,8 +106,11 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ After restore, **always** restart the runtime — it applies every pending core, workload-module and jobs migration on boot (over -`AF_STACK_MIGRATE_DATABASE_URL` when that is set) and exits non-zero if -they fail. Migrations are idempotent, so this also catches any schema +`AF_STACK_MIGRATE_DATABASE_URL` when that is set). A failed _core_ +migration exits non-zero; a failed workload-module or jobs migration is +logged and that module (or the jobs worker) is disabled while the runtime +keeps serving — so also check the logs for `migrations failed`, not just +`migrations applied`. Migrations are idempotent, so this also catches any schema drift between the backup vintage and the current code: ```bash diff --git a/docs-site/src/content/docs/reference/dashboard-plugins.md b/docs-site/src/content/docs/reference/dashboard-plugins.md index 50363252..4b84dabb 100644 --- a/docs-site/src/content/docs/reference/dashboard-plugins.md +++ b/docs-site/src/content/docs/reference/dashboard-plugins.md @@ -127,10 +127,10 @@ Field reference: Default-export a React component. Server components can use `api.*` helpers directly; client components should hydrate from server-rendered -data. The example `apps/dashboard/plugins/cost-explorer/page.tsx` shows -the recommended pattern: fetch with `Promise.allSettled`, degrade -gracefully when the runtime is unreachable, reuse the shared -`formatCurrency` helper from `(admin)/operate/cost/_components/format.ts`. +data. The example `examples/01-notable/dashboard-plugin/page.tsx` shows the +pattern: fetch server-side from the runtime and render an empty state when +it is unreachable. (`apps/dashboard/plugins/` is created by +`af-stack plugin new `; the repo ships no plugin there.) ### Run diff --git a/docs/architecture.md b/docs/architecture.md index 3e775b48..e6c4005b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -472,13 +472,14 @@ capabilities. Drop a directory under `workload-modules//`: ``` -manifest.yaml # routes, migrations, jobs, crons -handler.go # Go HTTP handlers -migrations/*.sql # schema additions +backai.module.yaml # id, version, resources + fields (check with `af-stack module validate`) +README.md +migrations/*.sql # schema additions (goose) ``` -The runtime's module loader mounts your routes at `/workload//...` -on next start. +The runtime's module loader mounts each resource at +`/api/v1/workload//` on next start. There is no handler +file and no jobs/crons field: modules are declarative. ### 10.5 A new dashboard plugin diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 8c330488..ba4397e5 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -106,8 +106,11 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ After restore, **always** restart the runtime — it applies every pending core, workload-module and jobs migration on boot (over -`AF_STACK_MIGRATE_DATABASE_URL` when that is set) and exits non-zero if -they fail. Migrations are idempotent, so this also catches any schema +`AF_STACK_MIGRATE_DATABASE_URL` when that is set). A failed _core_ +migration exits non-zero; a failed workload-module or jobs migration is +logged and that module (or the jobs worker) is disabled while the runtime +keeps serving — so also check the logs for `migrations failed`, not just +`migrations applied`. Migrations are idempotent, so this also catches any schema drift between the backup vintage and the current code: ```bash diff --git a/docs/dx/adapters.md b/docs/dx/adapters.md index d8395ba2..ff20e64e 100644 --- a/docs/dx/adapters.md +++ b/docs/dx/adapters.md @@ -17,7 +17,8 @@ It is an operator command: it needs the runtime up (`af-stack dev`), `AF_STACK_URL` (default `http://localhost:8080`), and `AF_STACK_API_KEY` set to an operator key — mint one with [`af-stack operator key`](../cli-admin.md#minting-an-operator-key) (needs -`DATABASE_URL`). The seeded operator in [run.md](run.md) is a dashboard +`DATABASE_URL`) — in **personal** mode the operator gate is off and no key is +required (see [run.md](run.md)). The seeded operator in [run.md](run.md) is a dashboard login, not an API key. ## The swappable slots diff --git a/docs/dx/run.md b/docs/dx/run.md index 3bca8045..31589af8 100644 --- a/docs/dx/run.md +++ b/docs/dx/run.md @@ -9,7 +9,9 @@ af-stack dev From inside the clone, that's the whole thing — see the [golden path](README.md) for the `git clone` line. Run it anywhere else and -it exits 1 with `must run from inside an AF Stack checkout`. `af-stack dev`: +it exits 1 with `must run from inside a BackAI checkout — a clone of +https://github.com/Agent-Field/backai …`, followed by the clone command and +the standalone-app alternative. `af-stack dev`: 1. Runs a **port preflight** (`scripts/preflight.mjs --fix`) — finds a free host port for each service, writes the overrides into `.env`, and diff --git a/docs/product.md b/docs/product.md index ead2d18a..e21988f6 100644 --- a/docs/product.md +++ b/docs/product.md @@ -46,7 +46,7 @@ another provider key when you want live model calls through LiteLLM: | **Audit log** | Every admin mutation (tenant create/delete, api_key create/revoke, secret put/delete/reveal, budget set, membership change) writes a row with actor, IP, user agent, metadata. | | **MCP host** | stdio + SSE adapters with JSON-RPC framing, 5-minute tool catalogue refresh, per-tenant scoping, env from secrets vault via `secret:` prefix. | | **Skills** | Install bundles, attach to agents, query installed list. | -| **Harnesses** | Probe-only — detects whether claude-code/codex/gemini/opencode is available in the agent container and what auth it needs. Surfaced by the operator dashboard's harness cards and `GET /api/v1/harnesses`, `GET /api/v1/harnesses/{provider}`, `POST /api/v1/harnesses/{provider}/probe`. There is no `af-stack harness` command on the operator CLI. | +| **Harnesses** | Probe-only — detects whether claude-code/codex/gemini/opencode is available in the agent container and what auth it needs. Reachable over `GET /api/v1/harnesses`, `GET /api/v1/harnesses/{provider}` and `POST /api/v1/harnesses/{provider}/probe`; there is no operator-dashboard page and no `af-stack harness` command on the operator CLI. | | **Operator dashboard** | Cost charts, run inspector, sandbox activity, memory browser, audit log, tenant drilldown, plugin system, theming via CSS variables. Plus operator pages for Secrets (vault CRUD + reveal/rotate), Crons (roster + trigger/pause), Flags, Cache (gateway hit rate + flush), Notifications (outbox + channels), and OAuth connections. | | **Customer-facing app** | Sign-up → help center → Support Chat → request history → billing/account pages. Runtime credentials stay internal to the app. Separate brand, same auth DB. | | **OpenAPI 3.1** | Auto-generated at `/openapi.json` with 86+ routes, 21 routes with curl+Python+TS code samples. | diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index c2ce4874..80a61216 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -387,16 +387,6 @@ async function capture() { fullPage: false, }) - // ── 26. Plugins → Cost Explorer (Phase 12.3) - console.log("→ Plugins → Cost Explorer") - await page.goto(`${DASHBOARD_URL}/plugins/cost-explorer`, { - waitUntil: "networkidle", - }) - await page.waitForTimeout(2000) - await page.screenshot({ - path: resolve(OUT_DIR, "plugin-cost-explorer.png"), - fullPage: false, - }) await browser.close() console.log(`saved screenshots to ${OUT_DIR}`) diff --git a/scripts/restore.sh b/scripts/restore.sh index 4e25f077..b53a4c05 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -57,9 +57,10 @@ gunzip -c "$FROM" | pg_restore --clean --if-exists --no-owner \ yellow "==> Schema may have moved on since the backup vintage." yellow " Restart the runtime to apply pending migrations (core, workload" -yellow " modules and jobs) — it runs them on boot and exits non-zero on" -yellow " failure. There is no 'af-stack migrate' subcommand." +yellow " modules and jobs). A failed core migration exits non-zero; a failed" +yellow " module or jobs migration is only logged and that module (or the jobs" +yellow " worker) is disabled. There is no 'af-stack migrate' subcommand." yellow " docker compose up -d --force-recreate runtime" -yellow " docker compose logs runtime | grep 'migrations applied'" +yellow " docker compose logs runtime | grep -E 'migrations (applied|failed)'" green "==> Restore complete. Validate with scripts/test-quickstart.sh." diff --git a/scripts/sdk-conformance/run.py b/scripts/sdk-conformance/run.py index 87b181f7..fb401cd5 100755 --- a/scripts/sdk-conformance/run.py +++ b/scripts/sdk-conformance/run.py @@ -101,6 +101,7 @@ async def check_agents_list() -> None: async def check_echo(client: BackAI) -> None: try: marker = uuid.uuid4().hex[:8] + # Assumes the stock node id; `af-stack init --name` renames it to .echo. res = await client.agents.call("supportdesk.echo", {"payload": {"message": marker}}) status = getattr(res, "status", None) or ( res.get("status") if isinstance(res, dict) else None diff --git a/scripts/sdk-conformance/run.ts b/scripts/sdk-conformance/run.ts index 187fd804..a8e7b6f9 100755 --- a/scripts/sdk-conformance/run.ts +++ b/scripts/sdk-conformance/run.ts @@ -87,6 +87,7 @@ async function checkAgentsList(): Promise { async function checkEcho(client: BackAI): Promise { try { const marker = uuid().slice(0, 8) + // Assumes the stock node id; `af-stack init --name` renames it to .echo. const res = await client.agents.call("supportdesk.echo", { payload: { message: marker } }) // Runtime returns the agent value under `result` (`output` is a // back-compat alias mirrored by the SDK); accept either. diff --git a/scripts/test-quickstart.sh b/scripts/test-quickstart.sh index 205d4833..2d673dcb 100755 --- a/scripts/test-quickstart.sh +++ b/scripts/test-quickstart.sh @@ -80,20 +80,25 @@ if [ -z "$REGISTERED" ]; then # Don't fail — agent discovery format may differ. We test invocation next. fi -step "5/5 POST to supportdesk.echo via the gateway" +# `af-stack init --name` rewrites the default agent's node id, so read it from +# compose instead of assuming "supportdesk". +NODE_ID="$(awk '/^ supportdesk-agent:/{f=1} f && /NODE_ID:/{print $2; exit}' docker-compose.yml)" +NODE_ID="${NODE_ID:-supportdesk}" + +step "5/5 POST to ${NODE_ID}.echo via the gateway" # AgentField's REST shape: {"input": {: }}. # Our `echo` reasoner takes one arg `payload: dict`, so we send: ECHO_RESP="$(curl -s -X POST \ -H "Content-Type: application/json" \ -d '{"input":{"payload":{"message":"hello world"}}}' \ - "http://localhost:${PORT}/api/v1/agents/supportdesk.echo")" + "http://localhost:${PORT}/api/v1/agents/${NODE_ID}.echo")" echo " response: $ECHO_RESP" if echo "$ECHO_RESP" | grep -q 'hello world\|echoed'; then - green " PASS supportdesk.echo round-trip works" + green " PASS ${NODE_ID}.echo round-trip works" else - red " FAIL supportdesk.echo did not return expected payload" + red " FAIL ${NODE_ID}.echo did not return expected payload" docker compose logs supportdesk-agent --tail 30 docker compose logs runtime --tail 30 exit 1 diff --git a/skills/af-stack/SKILL.md b/skills/af-stack/SKILL.md index 2880b9ae..ff5f6f97 100644 --- a/skills/af-stack/SKILL.md +++ b/skills/af-stack/SKILL.md @@ -34,8 +34,9 @@ af-stack mcp add github --transport stdio \ # register tool serv `af-stack init --name "" --template coding-agent` brands the checkout and adds a real coding agent (multi-tenancy ON, a GH_TOKEN secret slot). `--name` is -required: init only prompts for it on a TTY, so a non-interactive shell (what a -coding agent runs in) must pass the flag. Everything after is editing the four +required in practice: without the flag init prompts on stdin, and in a +non-interactive shell that read hits EOF and the command fails with +`init: --name is required` — so always pass it. Everything after is editing the four surfaces. Prefer these commands over hand-copying files. `af-stack init ` with a positional name is different: it scaffolds a small standalone app that calls a running BackAI, in any directory, with no diff --git a/skills/af-stack/rules/sdk.md b/skills/af-stack/rules/sdk.md index 4d3a55f1..c6676993 100644 --- a/skills/af-stack/rules/sdk.md +++ b/skills/af-stack/rules/sdk.md @@ -144,9 +144,9 @@ When you need a specific call: `packages/sdk-py/af_stack/.py` — these files have Sphinx docstrings that map every call to its REST endpoint. 2. **OpenAPI**: `GET /openapi.json` on a running runtime. Live truth. -3. **Existing code**: search how `examples/01-notable/dashboard-plugin/`, - `examples/starter/dashboard-plugin/`, and the sample agent use the - SDK — those are tested patterns. +3. **Existing code**: `scripts/sdk-conformance/run.ts` and `run.py` exercise + every SDK namespace against a live runtime — those are tested patterns. + (The example dashboard plugins use plain `fetch`, not the SDK.) ## LLM rate limits — 429 responses