AI-powered workspace assistant with multi-provider LLM support.
A standalone, loopback-only chat interface that understands your Agent-Workspace projects, context, memory, and runs. It connects to OpenAI, Anthropic, and Google Gemini — all from one unified interface.
The default UI is the desk: the workspace as a 2D floor - clients as zones, projects as
rooms, agents as animated figures whose pose shows what they are doing, with a raised hand
when one needs you. Two icons at the top right open the menu drawer (sections rendered by
uidl-runtime) and the assistant drawer. /?ui=classic keeps the previous chat layout.
Design, states, and phases: docs/desk/README.md; wireframes:
docs/desk/wireframe.md.
Requires ripgrep (rg) on PATH - search_code
shells out to it. Without it, search_code silently reports "No results found." rather
than failing loudly; CI installs it explicitly for the same reason.
# From the workspace root or this directory:
corepack pnpm install --frozen-lockfile
pnpm run dev
# Open the URL printed in the terminal (includes auth token)- Multi-provider LLM — any OpenAI-compatible, OpenAI Responses, or Anthropic endpoint by configuration, plus legacy OpenAI/Anthropic/Gemini settings
- Workspace-aware — knows your projects, context, memory, and runs via
wsCLI - Tool-Calling Diagnostic Suite — integrated with
dbakit(PostgreSQL),opskit(Linux/Docker/K8s), andagent-secure(security audits) - Authoritative Skill Retrieval —
get_skillpowered byAgent-Skills(48 standardized software engineering workflows) - Generative UIDL UI Preview — live in-chat interactive rendering of UIDL documents (
uidl-runtime) for metrics, forms, and cards - Studio Handoff — one-click
Studio ↗direct export intoUIDL-Builderfor visual drag-and-drop customization - Streaming chat — real-time SSE streaming with markdown rendering
- Code-aware — syntax highlighting, file previews, search results
- Secure — loopback-only, process-token auth, no secrets in source
Create ~/.agent-control/config.json (or use the in-app settings):
{
"providers": {
"openai": { "apiKey": "sk-..." },
"anthropic": { "apiKey": "sk-ant-..." },
"google": { "apiKey": "AIza..." }
},
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514"
}API keys are never stored in this repository. The config file lives outside the project directory and is git-ignored.
Any vendor, router, or local server that speaks one of three protocols is added by
configuration, not code: openai-chat (Chat Completions and compatible APIs, e.g. xAI,
OpenRouter, Ollama, vLLM), openai-responses, and anthropic-messages. A mock
protocol answers deterministically and offline, for tests and demos.
<workspace>/.local/agent/endpoints.json (outside every repository):
{
"endpoints": {
"anthropic": { "protocol": "anthropic-messages", "baseUrl": "https://api.anthropic.com/v1",
"apiKey": "env:ANTHROPIC_API_KEY", "models": ["claude-sonnet-5-5"] },
"xai": { "protocol": "openai-chat", "baseUrl": "https://api.x.ai/v1",
"apiKey": "env:XAI_API_KEY", "models": ["<model>"] },
"local": { "protocol": "openai-chat", "baseUrl": "http://127.0.0.1:11434/v1",
"models": ["<model>"] },
"mock": { "protocol": "mock", "models": ["echo"] }
}
}apiKey is a reference: env:NAME, keychain:NAME (macOS Keychain), or file:PATH for
a token file that only its owner can read (chmod 600; an absolute path or ~/...). A
literal key is rejected. An endpoint replaces a legacy provider with the same id.
A gateway that forwards to the cloud is remote. Agent-Brain's brain serve listens on
loopback but sends requests to cloud accounts, so declare it remote; a project's context
then reaches it only when that project's policy lists it (below). locality can only
tighten - a remote URL declared local is refused. List real model ids from
GET /v1/models; the code/chat aliases need a local backend.
"brain": { "protocol": "openai-chat", "baseUrl": "http://127.0.0.1:52140/v1",
"apiKey": "file:~/.lokal/serve.token", "locality": "remote",
"models": ["antigravity/claude-sonnet-4-6", "antigravity/gemini-3-flash", "codex/gpt-5.5"] }Egress per project (Agent-Workspace ADR-0021). Locality is derived from the URL:
loopback is local, everything else remote. A local endpoint is always allowed. A
remote endpoint is called only for a chat that names a project whose operator policy
lists it, in <workspace>/.local/agent/policies/<project>.json:
{ "schema_version": 1, "default": "read", "llm": { "endpoints": ["xai"] } }Without that entry the request is refused with 403. POST /api/agent/chat accepts
"project": "<key>" next to provider and model; the sidebar's Project selector
sends it, and disables providers the project may not use. Legacy providers from
~/.agent-control/config.json are checked the same way: remote unless their base URL is
loopback.
A chat bound to a project sees only that project (Agent-Workspace ADR-0011). Its
tools cannot list other projects, load another project's context pack, or read outside
the project's folder, its memory and runs, and the framework (.agents, docs,
AGENTS.md); search stays in the project folder; host diagnostics are not offered to a
remote model. Switching project starts a new conversation. GET /api/agent/projects lists the
projects and the endpoints each one allows.
Usage. Every finished chat appends one line to
<workspace>/.local/agent/usage/<YYYY-MM>.jsonl: endpoint, model, locality, project,
start/end, finish reason, and token totals - never the prompt or the answer.
A project-bound chat can run a command in its own project folder with the run_command
tool. What it may do without a human is decided by the project's operator policy
(.local/agent/policies/<project>.json, Agent-Workspace ADR-0014):
{ "schema_version": 1, "default": "approval-required", "actions": { "run_command": "change" } }read/change: the command runs immediately.approval-required(the default): the tool returns a request id instead of running.GET /api/agent/approvals?project=<key>lists pending requests;POST /api/agent/approvals/decidewith{"project", "id", "decision": "approved" | "denied"}decides one. The model is told to relay the id and wait, then call the tool again withapprovalIdset.- Before running, the approval is re-verified against the command, the project
repository's current
HEAD, and the policy file's hash as they are right now (Agent-Workspace ADR-0020). Any of the three changing since the request voids it; a decided approval runs at most once. - A missing or invalid policy refuses the action - it is never run on uncertain footing.
A chat can work inside a ws agent start worktree instead of the project's primary
checkout (Agent-Workspace ADR-0019):
POST /api/agent/coding-sessions/<project>/start -> starts one, or returns the active one
GET /api/agent/coding-sessions/<project> -> the active session, or null
POST /api/agent/coding-sessions/<project>/stop -> stops it (the worktree is kept, for review)
One session per project at a time. Once active, every tool that touches the project's
source moves to the worktree: write_file and edit_file (both gated like
run_command), git_diff, run_command's commands, and read_file/search_code -
the chat sees its own edits, not the unedited primary checkout. Reads of the project's
memory, runs, or the framework itself are unaffected; a coding session never changes
those.
ws web --agent starts this host beside the Agent-Workspace control plane (ADR-0019) and
makes it the single browser-facing origin:
/serves the workspace control desk (WS_DESK_DIST)./api/agent/*is this host's own API (chat, providers, projects, approvals, coding sessions, workspace).- every other
/api/*is proxied to the control plane (WS_CONTROL_URL) with the internal token (WS_CONTROL_TOKEN), rewritingHostand dropping the browser'sOriginandSec-Fetch-*headers. The browser never receives the control-plane token.
Without WS_AGENT_MODE=1 the host is a standalone app: it serves its own UI at / and
answers its API at /api/agent/*.
- Frontend: React 19 + Vite + TypeScript + Vanilla CSS
- Backend: Hono (Node.js) with SSE streaming
- LLM: Vercel AI SDK 7; the chat streams Agent-Control's own line protocol (
server/stream-protocol.ts) - Workspace:
wsCLI bridge for project/context awareness
pnpm test # node:test through tsx; makes no network call
pnpm lint
pnpm buildserver/chat.e2e.test.ts starts the real entry point (server/index.ts) against a
disposable workspace whose mock endpoint answers deterministically and offline, then
drives POST /api/agent/chat over HTTP. The whole chat path - loopback gate, token,
endpoint load, egress policy, the AI SDK stream, and the line protocol - runs with no
network call and no real provider. That is also the seam the browser harness drives.
pnpm exec playwright install chromium # once, or use PW_CHANNEL=chrome for an installed Chrome
pnpm test:uitest:ui builds the UI, starts the same real entry point against a disposable workspace
(test/ui/workspace.ts - endpoint config, policy, seeded approvals and coding session, a
stub ws), and drives it in Chromium: the chat transcript, the approval inbox, the
coding-session control, the settings dialog (masked keys, and a save that keeps the stored
ones), the in-chat UIDL preview, and the token the server injects into a page whose URL has
none. No network call and no real provider, exactly like the server test.
It needs git on PATH, because a started session's branch is read from the fixture
worktree. CI runs pnpm test, not this, so the browser download stays off the runner; it is
typechecked all the same, since tsc -b covers test/ui.
- Server binds to
127.0.0.1only — no network exposure - Bearer token generated per process — handed to the page it serves, never persisted
- File reads sandboxed to workspace root
- CSP headers prevent script injection; the one injected token script runs behind a fresh per-response nonce, and the HTML that carries it is not cacheable
- API keys stored outside project in user home directory
MIT