Skip to content

Latest commit

 

History

History
659 lines (539 loc) · 32.5 KB

File metadata and controls

659 lines (539 loc) · 32.5 KB

kbagent serve — HTTP server + agent host + Web UI

TL;DR

kbagent serve is the kbagent kernel exposed as an HTTP API, plus a local agent host that schedules background tasks (cron + kbagent CLI runs + AI CLIs), plus a React web UI that drives both. Single Python process, localhost-only, bearer-auth, scoped to one config directory.

┌─────────────────────────────────────────────────────────────┐
│ Browser ── React SPA (web/frontend, Vite :5173)             │
│   │                                                         │
│   │  REST + SSE  via /api/*                                 │
│   ▼                                                         │
│ Node BFF (web/backend, Fastify :8000)                       │
│   │  injects bearer token                                   │
│   ▼                                                         │
│ kbagent serve (Python FastAPI :8001)                        │
│   ├─ REST endpoints over every kbagent service              │
│   ├─ asyncio cron scheduler (agent_runner)                  │
│   └─ subprocesses: kbagent CLI / claude / codex / gemini    │
│       └─ they call back via `kbagent http` to *this* serve  │
│   │                                                         │
│   ▼                                                         │
│ Keboola APIs (Storage, Queue, Manage, AI, ...)              │
└─────────────────────────────────────────────────────────────┘

Why this exists

Three stages, each adds capability over the previous one.

Stage 1 — API for programmatic access (today)

The CLI is fine when a human types kbagent project list. It is awkward when something programmatic — a different tool, a Streamlit app, a notebook, a webhook — wants to ask "what configs are in project X?" Forking a CLI per question is slow, swallows logs, and re-parses JSON output.

kbagent serve solves that by exposing the same Python services as a proper HTTP API with an OpenAPI schema. Anyone can build apps on top, in any language. The CLI itself stays unchanged; the server is a parallel surface.

Stage 2 — Local agent host (today, in progress)

Once you have a long-running serve with a token, the obvious next step is "let me schedule things to run inside it". Two flavours:

  • cli_command — periodic kbagent <cmd> runs (the cron-for-kbagent use case).
  • ai_agent — periodic prompts to a local AI CLI (claude, codex, gemini). The AI can use its own tools (file ops, web search, its own MCP servers) to satisfy the prompt, and it can call back into this serve via kbagent http get /… because the scheduler injects the serve URL + bearer token into the subprocess environment.

The result is a local control plane: agents that wake up on a cron, do work using the user's own AI subscription, and write the result back to a run history that the UI displays.

Stage 3 — Replace the Keboola UI for everyday work (vision)

The endgame: the operator's daily Keboola interaction happens here, not on connection.keboola.com. They open the local UI in the morning, see overnight agent results, ask Kai a question, run a SQL workspace, review job failures — without ever opening the official UI. Power users keep the official UI for the things this UI doesn't cover; everyone else lives here, with their own agents that know their projects.

Capabilities

REST API (/api/* after BFF, / directly on serve)

One router per kbagent service area, each mirroring that area's CLI commands. The endpoint list is not maintained here — it lives in web-server-endpoints.md, generated from the running app's own OpenAPI spec and gated in CI by make endpoints-check, so it cannot drift from the server. This section describes the shape of the surface; that file is the inventory.

The routers group into the categories declared in server/app.py::OPENAPI_TAGS (which is also what tabs the Swagger UI):

Category Routers
Project Management auth projects members org feature billing token
Configurations configs components transformations encrypt
Data storage stream search sharing
Execution jobs flows schedules notifications data-apps workspaces
Development branches lineage semantic-layer
AI & Tools kai documentation ai-chat agents
Read-only dev-portal
System health (plus /version, /changelog, /doctor, /ui-config)

ai-chat is the one router with no CLI counterpart — it exists to stream the web UI's chat. agents mirrors kbagent agent * (both sides read the same agents.json); what is serve-only there is the cron loop, so a scheduled task fires only while the server runs. auth mirrors only the read/audit half of kbagent auth (since v0.90.1) — login / login-password / logout deliberately have no endpoint — and it is so far the only router that enforces the permission policy; see "/auth/* — three read/audit endpoints, three deliberate gaps" below.

Going the other way, several CLI surfaces are deliberately CLI-only: sync (filesystem-local by design), permissions, and init. The mirrors still considered missing are tracked in #657, and the fact that permissions constrains only /auth/* and not the other ~30 routers is #655.

Auto-generated OpenAPI spec at /openapi.json, Swagger UI at /docs.

An upstream Keboola failure surfaces through one global handler: a NOT_FOUND answers 404 (since 0.90.0 — it used to be 502, which told callers to retry a request that can never succeed), an expired/missing browser session answers 401, and every other KeboolaApiError answers 502. The body is always the {"status": "error", "error": {"code", "message"}} envelope, so the error.code — not the HTTP status alone — is what a client should branch on. GET /components/{id} in particular no longer 404s for a component the AI Service does not index: it falls back to the project's Storage catalog and marks the response documentation_source: "storage_catalog".

Streaming endpoints (Server-Sent Events)

Six routes stream instead of returning a body. They are ordinary entries in the generated reference, so this list is about why each one streams:

  • GET /jobs/{project}/{job_id}/stream — live job status transitions + log tail. The only one using sse_starlette's EventSourceResponse; the rest return a StreamingResponse with media_type="text/event-stream".
  • POST /agents/{task_id}/run/stream — a scheduled task run, streamed as it happens rather than polled.
  • POST /agents/test/stream — the same for an ad-hoc (unsaved) action.
  • POST /agents/prompt/improve/stream — AI prompt rewriting; streams because the underlying AI CLI does.
  • POST /ai/chat/stream — local AI chat responses, token by token.
  • POST /workspaces/sql/improve/stream — AI SQL helper over a workspace.

Note that GET /agents/{task_id}/runs/{run_id}/events looks like a stream but is not: it replays a finished run's event timeline as one JSON response.

Never wired (no route exists): /branches/{project}/reset/stream, /lineage/build/stream, /kai/chat/stream — all three remain plain request/response despite being long-running.

Agent scheduler

A single asyncio coroutine, started on FastAPI lifespan, that ticks every minute and dispatches due tasks. State lives in the resolved config directory:

  • <config_dir>/agents.json — task definitions (mode 0600).
  • <config_dir>/agent_runs/<task_id>.jsonl — append-only run history.

croniter parses the cron expression. Each task records last_run_at and next_run_at so re-runs after restarts pick up where they left off.

Web UI (web/frontend)

A NERD-themed React SPA that drives the API:

  • Command palette — Ctrl+K / Cmd+K anywhere: fuzzy jump to any page, switch the active project, toggle the theme, open Swagger /docs, reopen What's new. Arrows + enter, esc closes.
  • What's new popup (since 0.90.0) — a curated per-version highlights modal, shown once per version. See What's-new popup below for the curated list's location, the storage key, and the --no-banner opt-out.
  • Dashboard — greeting, big Kai chat input, stat tiles (projects / agents / doctor / recent jobs / PAYG credits), scheduled-agent activity, suggested next steps, recent jobs panel. The credits tile reads GET /billing/credits for the active project and shows a muted n/a on a non-PAYG project (PAYG_NOT_AVAILABLE).
  • Projects, Branches, Doctor, Changelog — manage local config and health.
  • Tokens — scoped Storage tokens for the selected project (/token/{p}/list): create / rotate / revoke, with the secret revealed ONCE in a copy-to-clipboard block, and an opt-in "derive last-used" toggle (with_last_used=true) that sorts dormant tokens first and renders never / unknown / error as distinct pills. A cross-project All Tokens view reads GET /token/list (repeatable ?project=, same convention as /jobs and /billing/credits; omitted = every registered project) — every row carries project_alias, and with_last_used=true sorts dormant-first across every project's tokens together rather than grouped per project.
  • Configs, Components (AI search), Storage (with per-column data preview), Jobs (cards layout + SSE log stream), Search — browse a selected project.
    • Configs: detail Drawer with Run job (POST /jobs/{p}/run) and Delete (soft-delete via DELETE /configs/…), plus a Trash tab (GET /configs/trash/{p}) with per-row Restore.
    • Storage: the table drawer's Info tab renders the raw definition (time / range partitioning, clustering, partition filter, partition count) when the stack reports one, and the Schema tab's Description cell is click-to-edit through POST /storage/columns/{p}/{id}/describe.
    • Jobs: per-row re-run and terminate (POST /jobs/{p}/run / …/terminate), the latter behind a confirm modal.
  • SQL Workspaces — info Drawer with credentials + actions sidebar; Open SQL Editor opens a Monaco editor with a clickable Storage Explorer tree on the left.
  • Flows — visual Mermaid builder of phase DAG + per-phase task list, plus a read-only Notifications tab (GET /notifications) listing who gets paged for the flow, with filter-less project-wide subscriptions shown in their own warning-pilled group.
  • Schedules — cross-project cron list + find-by-window query.
  • Data Apps — list, start/stop, secrets, validate-repo.
  • Lineage — Sharing graph (live, cross-project) + Deep lineage (UI triggers sync pull + lineage build, then renders the JSON cache).
  • Kai Chat — chat history scoped to the current project.
  • Agent Tasks — cron-scheduled tasks (kbagent CLI / AI agent), with run-now, run history with AI-response/stdout panels, and an ad-hoc "Test now" button on the create form (runs through /agents/test, same code path as the scheduler, no persistence).
  • Org Setup, Members, Encrypt — admin / write actions that need a Manage API token. The UI prompts for it per-action via a hidden modal, forwards as X-Manage-Token for that one request, never persists.

Shareable deep links. Every view is addressable, so a URL copied out of the address bar reopens exactly what the sender was looking at. The whole navigation state lives in the location hash: #/<page> for a page with no project context, #/p/<project>/<page> once a project is selected, ?branch=<id> for a dev branch, and ?sel=<object> for the page's selected object — a job id on Jobs, <component>/<config> on Configs, and so on. The hash rather than a path, because the SPA is mounted at the root of the same FastAPI app that serves the REST API: a history-mode /projects would collide with the endpoint that returns JSON, while everything after # is never sent to the server at all. sel is opaque to the router — the page that writes it defines its shape, and it is dropped on any page / project / branch change, since an object id from one context means nothing in the next. A link whose object no longer resolves in the current project simply opens the page with no drawer.

Detail drawers render an Overview tab (the payload's fields as labelled sections, with pills for status and nested blobs kept verbatim) and keep the untouched response one click away under Raw JSON with a copy button, so nothing the API returned is ever hidden.

Architecture

Three processes, three languages, one HTTP/JSON contract between each pair. The boundary is intentional — you can swap any tier without touching the others.

Tier 1 — kbagent serve (Python, FastAPI)

src/keboola_agent_cli/server/:

app.py             create_app() factory, lifespan that starts the scheduler,
                   OPENAPI_TAGS, global exception handlers, UI mount
__init__.py        PEP 562 lazy re-export of create_app, so importing a
                   pure-logic sibling does not drag in FastAPI
auth.py            BearerAuthMiddleware (random token on startup)
dependencies.py    ServiceRegistry — singleton holding every kbagent service
agents_store.py    AgentTask / AgentRun + JSON file persistence
agent_runner.py    cron loop + per-action-type dispatch + subprocess env
run_broadcaster.py one live run per task, fanned out to every watching tab
                   (late attach replays what earlier viewers already saw)
pricing.py         per-model USD cost + token breakdown on each persisted run
sse.py             SSE helpers
routers/           one file per service area (jobs.py, storage.py, …)

Reuses every existing kbagent service unchanged — services already return JSON-friendly dicts because the CLI's --json mode demanded it.

Tier 2 — Node BFF (TypeScript, Fastify)

web/backend/:

src/server.ts      Fastify entry — listens on :8000
src/proxy.ts       /api/* → kbagent serve, attaches Bearer token, SSE pass-through
src/config.ts      reads KBAGENT_SERVE_URL + KBAGENT_SERVE_TOKEN from env

No business logic. The Bearer token never leaves this process; the browser never sees it.

Tier 3 — React UI (TypeScript, Vite)

web/frontend/:

src/api/client.ts          fetch wrapper + SSE subscriber
src/state.tsx              React Context for active project / branch / page
src/types.ts               TypeScript shapes mirroring server responses
src/layout/                Shell, Sidebar, TopBar (project + branch picker), StatusBar
src/components/            Drawer, DataTable, JsonView, Empty, ManageTokenModal
src/pages/                 one file per area (Dashboard, Storage, Jobs, …)
src/App.tsx                state-driven router (no react-router)

Tailwind for styling, TanStack Query for fetching, Monaco for SQL, Mermaid for graphs.

How to run

Requirements

  • Python 3.12+ with the optional server extra:
    uv pip install -e ".[server]"
  • Node 20+ for the BFF and frontend:
    make web-install

One command (recommended for development)

CONFIG_DIR=/path/to/.kbagent make web-dev

Spawns kbagent serve, the Node BFF, and Vite in a single foreground process. Output is line-prefixed ([serve], [bff], [vite]); Ctrl+C stops everything. Open http://localhost:5173.

Three terminals (HMR per tier)

# Terminal 1 — kernel
uv run kbagent serve --port 8001 --config-dir /path/to/.kbagent
# copy the printed token

# Terminal 2 — BFF
cd web/backend
KBAGENT_SERVE_TOKEN=<token> PORT=8000 npm run dev

# Terminal 3 — frontend
cd web/frontend
npm run dev

Production-ish (no Vite)

make web-build
uv run kbagent serve --port 8001 --config-dir ~/.config/keboola-agent-cli &
cd web/backend
STATIC_DIR=../frontend/dist KBAGENT_SERVE_TOKEN=<token> PORT=8000 npm start

The BFF then serves the React build statically and proxies /api/*.

Key concepts

Bearer token at startup

kbagent serve mints a random 32-byte URL-safe token on every start (unless KBAGENT_SERVE_TOKEN is pre-exported), prints it once to stdout, and refuses any request that does not present it as Authorization: Bearer <token>. Public paths: /health/ping, /health/auth-info, /openapi.json, /docs, /redoc.

Session cookie in single-process UI mode

With kbagent serve --ui, the browser never sees the bearer token: GET / (and GET /index.html) answers the SPA shell with a Set-Cookie: kbagent_session=<token>; HttpOnly; SameSite=Strict; Path=/ session cookie, and the auth middleware accepts that cookie whenever no Authorization header is present. Scripted callers keep using the header.

Two layers keep that cookie from going stale across server restarts (since 0.90.0) — previously a restart (new token) could leave a tab that reloaded from the browser cache silently 401-ing on every API call, with each list rendering as empty:

  • The shell is served with Cache-Control: no-cache, so a reload always revalidates against the server — and the bootstrap route always answers a full 200 with a fresh Set-Cookie.
  • The SPA's API client treats a 401 as "cookie may be stale": it re-fetches / once with cache: "reload" (bypassing every cache layer), retries the request, and only if the retry still answers 401 shows a visible Session expired banner (for SESSION_EXPIRED / SESSION_NOT_FOUND the banner carries the server message, which names the on-host kbagent auth login remedy).

What's-new popup

(since 0.90.0) The web UI shows a curated per-version highlights modal on load, once per version, so features like the command palette get discovered instead of waiting to be stumbled upon.

Curated list — web/frontend/src/whatsnew.ts. A hand-maintained WhatsNewRelease[], deliberately not the raw changelog.py output: the changelog records everything, this reel records the handful of things a UI user should look at. Release PRs that ship user-visible UI features must add an entry here — same pass that resolves vNEXT placeholders. Adding one is a single array element:

{ version: "0.90.0", items: [{ title: "…", body: "…", hint: "ctrl+k" }] }

A release with no entry of its own is not silent: the UI falls back to the newest entry at or below the running version, so users still see the most recent curated reel (each shown at most once). Exact matching would make the feature ship dark — the popup first runs in the release after the one whose highlights seeded the list. Silence happens only when no entry is <= the running version.

Mechanics.

  • Dismissal is persisted to localStorage["kbagent.whatsnew.seen"] as the release version string; the popup reappears only when the running version moves to another curated entry. A PEP 440 pre-release suffix is stripped before matching, so 0.90.0b1 sees the 0.90.0 reel.
  • Esc, a backdrop click, and the "got it" button all dismiss and persist.
  • The command palette's What's new action reopens it on demand, ignoring both the seen marker and --no-banner.

Opt-out — kbagent serve --no-banner. Suppresses the unsolicited popup fleet-wide (an explicit palette request still works). The SPA reads the switch from GET /ui-config -> {"banner": bool}, and fails closed — while that request is in flight or if it fails, no popup.

It is an endpoint rather than something injected into index.html, for two reasons. There is no injection point to extend: the one that existed (window.__KBAGENT_TOKEN) was removed in favour of the session cookie, and tests/test_serve_ui.py asserts it stays gone. And injection would only cover GET / and GET /index.html — the SPA shell is also served by the StaticFiles html=True fallback for any unmatched path, and that copy would carry no config, silently re-enabling the popup an operator had suppressed. For a suppression flag, failing open is the wrong direction.

Session-registered projects

Projects registered through kbagent auth login --register-projects carry a kbc-session://<project_id> sentinel instead of a Storage token; the live credential is a browser-login session in auth.json on the host. serve supports them, because it never turns a project into credentials itself — every service in the registry resolves its own client factory, so the REST API inherits the same session behaviour the CLI has (server/dependencies.py). A session reaches almost every command over REST, exactly as on the CLI. The few features that still need a static token fail fast with AUTH_NOT_SUPPORTED_ON_STACK and name the fallback. The authoritative list is SESSION_UNSUPPORTED_FEATURES in services/_auth_registration.py. See Browser login for the same list in prose.

A session that expires while the server runs answers HTTP 401 with error_code: SESSION_EXPIRED. The server cannot recover on its own: a browser login only completes where a human sits, so the remedy is kbagent auth login on the host running serve, not anything the REST caller can do.

Two properties of this are consciously accepted, traded for being able to drive a session-backed project from the web UI at all:

  • The serve token borrows a user identity. A browser-login session is USER-scoped, so whoever holds KBAGENT_SERVE_TOKEN acts as the signed-in Keboola user for as long as that session lives. The serve token is not that user's Keboola identity and the REST surface has no second identity layer to distinguish them — treat the serve token as equivalent to the session it can reach, and keep the default localhost bind unless you have a reason not to.
  • Rotation was designed for short invocations. Refresh-token rotation assumes a CLI process that exits in seconds. In a daemon up for weeks, the crash window between persisting a rotated session and revoking the previous one stays open far longer, and a crash inside it leaves a server-side session that no later auth logout can revoke. Such a session expires on its own schedule; it cannot be revoked from this CLI afterwards.

For a project you would rather not expose this way, register it with a static Storage token (kbagent project add --token) — that path has neither property.

/auth/* — three read/audit endpoints, three deliberate gaps

(since v0.90.1) kbagent auth now has a server/routers/auth.py counterpart, but it mirrors only the read/audit half of the CLI group:

Endpoint CLI equivalent Permission op
GET /auth/projects?stack= the interactive picker inside auth register-projects (no CLI leaf command of its own) auth.projects (read)
POST /auth/register-projects auth register-projects --all / --project-id ID ... auth.register-projects (write)
GET /auth/status?stack= auth status auth.status (read)

POST /auth/register-projects takes a body of {stack?, all?, project_ids?, aliases?} (all is the wire alias for the service's select_all; aliases maps a numeric project id to an alias override, coerced from the JSON body's string keys) and returns the same registered / exists / skipped per-project statuses the CLI prints — an existing alias is never overwritten. None of the three response shapes (ProjectCandidatesResult, RegisterProjectsResult, AuthStatusResult) ever carries a token value, including the kbc-session:// sentinel.

/auth/* is also the first router to enforce the permission policy: every route above declares Depends(require_permission(...)), so a denied operation answers HTTP 403 with error_code: PERMISSION_DENIED — the same code the CLI exits on. The other ~30 routers do not check the engine yet; see the gotchas entry on this before assuming a deny policy firewalls the whole REST surface.

The policy in force is the persisted permissions block of the config dir serve resolves, plus whichever session flags the kbagent invocation carried. Two consequences worth knowing before you reach for a flag:

  • kbagent --deny-writes serve never starts the server. serve is classified admin, and --deny-writes appends cli:write, which spans write + destructive + admin — so the CLI callback blocks the serve command itself (exit code 6, Operation 'serve' is blocked by the active permission policy). --deny-destructive does start the server, but no /auth/* operation is destructive, so it changes nothing here.

  • Use a persisted policy instead. Run, on the host, in a real terminal (permissions set requires a typed confirmation code — there is no --yes):

    kbagent --config-dir /path/to/cfg permissions set \
        --mode allow --deny auth.register-projects
    kbagent serve --config-dir /path/to/cfg --port 8001

    POST /auth/register-projects then answers 403 PERMISSION_DENIED while GET /auth/projects and GET /auth/status stay reachable. A --mode deny policy works too, but its allow list must then include serve (and the reads you want to keep), or the server will not start for the same reason as above.

    Either spelling of --config-dir selects the served directory (since 0.91.0) — see Which config directory serve uses below. On 0.90.1 and older, only serve --config-dir did: a root-level kbagent --config-dir ... serve was ignored, so the policy above was silently not the one enforced.

A missing or expired session reaches GET /auth/projects and POST /auth/register-projects as a thrown error, both funnelled through AuthService._introspect_accessible_projects: no stored session raises SESSION_NOT_FOUND, a stored session whose refresh fails raises SESSION_EXPIRED (via provider.introspect()) — both answer HTTP 401, same as every other session-project failure documented above. GET /auth/status is the deliberate exception: it is the probe you call to find out whether a session is dead, so it must not itself fail that way. AuthService.status() catches both cases and always answers HTTP 200, reporting session health in the response body's status field instead — "missing" (no stored session), "expired" (refresh failed), "degraded" (the auth service was unreachable; locally stored data is shown), "refreshed" (introspection rotated the access token), or "live".

Scope that exactly: for a missing or expired session /auth/status answers 200 and reports health in status; an unresolvable stack (4xx) or an unexpected auth-service failure (502) still surface as errors. The stack must resolve before any session is looked at — with no ?stack= and no default project to fall back on, AuthService.status() raises ConfigError and the route answers 4xx — and any KeboolaApiError that is neither SESSION_EXPIRED nor a network code is re-raised rather than swallowed, so it reaches the central handler (502, or 401 for a session-credential code such as SESSION_NOT_FOUND). So a client detecting a dead session by HTTP status alone must call /auth/projects or /auth/register-projects; on /auth/status a 200 is the normal answer for a dead session and the caller must read status from the body.

Registering a project through POST /auth/register-projects writes the same kbc-session://<project_id> sentinel auth login --register-projects would — so whoever holds KBAGENT_SERVE_TOKEN can grow the set of session-backed projects this server exposes, still acting as the signed-in user for all of them, per "Session-registered projects" above.

login / login-password / logout deliberately have no endpoint:

  • auth login opens a browser (or prints a device-flow code) on the host and only completes there — a REST caller has no way to sit in that loop.
  • auth login-password takes a plaintext password (and, for MFA accounts, a TOTP seed) meant to flow from a CI secrets store into one kbagent CLI invocation, never as a REST request body sitting behind this server's own bearer token.
  • auth logout revokes the live session backing every session-registered project reachable through this very server. Destroying that session is a deliberate host-operator action taken at the CLI, not something a REST client holding the serve bearer token should be able to trigger remotely.

Sign in via the CLI directly (kbagent auth login-password, or auth login for a human), then use POST /auth/register-projects — or auth register-projects on the CLI — to register the resulting session's projects for serve to use.

Manage tokens are per-request

Operations that need a Keboola Manage API token (org setup, project invite, member-set-role) read it from an X-Manage-Token header, use it for that single request, and discard it. The token is never logged, never stored, and the env-var fallback that the CLI has (--allow-env-manage-token) is not exposed by the server.

Agents call back via kbagent http

When the scheduler spawns an AI agent (claude -p …), it overlays three env vars onto the child process:

  • KBAGENT_CONFIG_DIR — same config the serve uses, so any kbagent <cmd> the AI runs sees the same projects + tokens + active branches.
  • KBAGENT_SERVE_URL — http://127.0.0.1:8001.
  • KBAGENT_SERVE_TOKEN — the bearer token.

Plus a short instruction prefix is prepended to the user's prompt telling the AI: "you can call this serve via kbagent http get /… — that is the preferred path because it shares state with this very process, instead of forking a CLI tree against possibly stale config."

This is what lets a midnight agent task do meaningful work: it has the same view of Keboola the operator does, it can call any endpoint in the reference, and its full response (including any tools it called) is captured into the run history.

Which config directory serve uses

serve is the only subcommand with a --config-dir of its own, so there are two places the flag can appear. Most specific wins (since 0.91.0):

  1. kbagent serve --config-dir X → serves X.
  2. kbagent --config-dir Y serve → serves Y.
  3. Neither → KBAGENT_CONFIG_DIR, then the .kbagent walk-up from the CWD, then the global directory (config_store.resolve_config_dir).

Giving both is not an error — the serve-level flag simply wins, as in rule 1. Only an explicit root flag is forwarded; an env-var/walk-up/global resolution is left to the server, which reaches the identical directory on its own.

On 0.90.1 and older, rule 2 did not exist (issue #679): the root-level --config-dir was ignored by serve entirely, with no warning. The server then exposed a different set of projects than the caller named, and — once /auth/* began enforcing permissions in 0.90.1 — enforced a different directory's policy. On those versions always pass --config-dir to serve itself.

State on disk

Everything the server persists lives under one config directory (resolved via --config-dir, KBAGENT_CONFIG_DIR, or the standard walk-up rules from config_store.py):

<config_dir>/
  config.json                projects + tokens + permissions (mode 0600)
  agents.json                scheduled tasks (mode 0600)
  agent_runs/
    <task_id>.jsonl          append-only run history

Nothing else. No database, no cache that survives restart, no shared state with other serve instances. Multiple serves on different ports (or different config dirs) are independent — no leader election, no coordination. That is intentional for now: the singleton model fits the personal-control-plane vision; multi-tenant comes later if at all.

Where to look next

  • src/keboola_agent_cli/commands/serve.py — CLI entry point, argv parsing.
  • web-server-endpoints.md — every route, generated.
  • src/keboola_agent_cli/server/app.py — create_app() + lifespan + OPENAPI_TAGS (the router categories above).
  • scripts/gen_endpoint_reference.py — generates the endpoint reference; make endpoints-gen after adding a route, or CI fails.
  • src/keboola_agent_cli/server/agent_runner.py — scheduler + dispatch.
  • src/keboola_agent_cli/server/routers/agents.py — agent-tasks API.
  • src/keboola_agent_cli/commands/http_client.py — kbagent http subcommand used by AI subprocesses to call the live serve.
  • web/README.md — frontend-specific quickstart.
  • tests/test_server_smoke.py — minimal end-to-end check that the app builds and routes resolve.