Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 19 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<node_id>.echo`, where
`<node_id>` 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 \
Expand Down Expand Up @@ -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 <helm|fly|railway|render>`.
- **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
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ 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; 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,
Expand Down Expand Up @@ -138,7 +145,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
```

Expand Down
6 changes: 4 additions & 2 deletions apps/customer-app/EDITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<route>/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 `<NavMain>`)

Start from `examples/starter/customer-app/first-action/page.tsx` when
adding the first logged-in workflow.
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/lib/bootstrap-operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 23 additions & 13 deletions deploy/helm/af-stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> --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

Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions docs-site/src/content/docs/deploy/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
5 changes: 4 additions & 1 deletion docs-site/src/content/docs/guides/customize-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/`. No plugins ship enabled by default; the
directory is created by `af-stack plugin new <id>`.
Full guide: [Reference → Dashboard Plugins](/reference/dashboard-plugins/).

## What you DON'T need to fork for
Expand Down
63 changes: 45 additions & 18 deletions docs-site/src/content/docs/reference/backup-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,27 @@ 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). 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
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`:
Expand All @@ -135,23 +148,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=<new 64-char hex>` 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
`<env>-pre-<timestamp>`.
**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 <key> --value-stdin`.
5. Archive the old key material labelled `<env>-pre-<timestamp>` —
without it, any row you missed in step 2 is gone.

## Backup verification

Expand Down
8 changes: 4 additions & 4 deletions docs-site/src/content/docs/reference/dashboard-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`; the repo ships no plugin there.)

### Run

Expand Down
20 changes: 16 additions & 4 deletions docs/adapters/AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slot>-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 <slot> [name]`
writes a skeleton that already serves `/healthz`, `/v1/capabilities`
and `/v1/info` (FastAPI; `--dir <parent>` 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 <slot> --url http://localhost:PORT`
5. Ship a container image. Operators plug you in by setting env vars:
Expand Down Expand Up @@ -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_<SLOT>_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.
Expand Down
11 changes: 6 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,13 +472,14 @@ capabilities.
Drop a directory under `workload-modules/<id>/`:

```
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/<id>/...`
on next start.
The runtime's module loader mounts each resource at
`/api/v1/workload/<id>/<resource>` on next start. There is no handler
file and no jobs/crons field: modules are declarative.

### 10.5 A new dashboard plugin

Expand Down
Loading
Loading