# Downcity — Full Documentation
> Complete text of the Downcity documentation, SDK references, and articles.
> Generated from the same MDX sources that render the website.
## Docs
### Downcity Docs
Source: https://downcity.ai/en/docs/
Downcity is agent infrastructure for AI builders. These docs help you run local agents, connect City, connect models and powers, and turn agent workflows into real product surfaces.
The shortest product model:
- products own the user experience
- agents execute work inside explicit runtime boundaries
- Downcity provides the reusable runtime layer for models, tools, tasks, memory, services, permissions, usage, billing, and control surfaces
## Start Here
## Run and Control
- [Agent](/en/docs/agent/overview/): execution capabilities and runtime boundaries.
- [Power](/en/docs/power/overview/): tools and integrations.
- [City](/en/docs/city/overview/): hosts, resources, and workspaces.
- [Federation & Service](/en/docs/federation/overview/): shared models and product services.
- [Security](/en/docs/security/overview/): permissions, approvals, and isolation.
- [CLI](/en/docs/cli/overview/): manage your local runtime from the terminal.
## Documentation Spaces
- [Products](/en/docs/): product-level docs: Agent, Power, City, Federation, security, and reference.
- [Agent SDK](/en/agent-sdk-docs/): programmable agents, sessions, local/remote agent, model binding, and power APIs.
- [City SDK](/en/city-sdk-docs/): `City`, `Federation`, downcity identity, tokens, service/action infrastructure, and deployment.
- [Payments](/en/payments/): accounts, balance, usage, and Stripe payment services.
- [Agent Powers Docs](/en/powers-docs/): built-in power manuals and power examples.
- [UI SDK](/en/ui-sdk-docs/): reusable UI components and Workboard surfaces.
### Getting Started
Source: https://downcity.ai/en/docs/quickstart/
Downcity has several user surfaces. This page is a map, not a command tutorial.
Use it to decide where to start:
- Use [Downcity CLI](/en/docs/reference/cli) when you want to run local agents, manage project agents, or use built-in power commands.
- Use [CLI](/en/city-sdk-docs/packages/cli) and its `fed/downfed` commands when you want to create, manage, or deploy a Federation project.
- Use [Agent SDK](/en/agent-sdk-docs) when you want to embed agent behavior in your own Node.js application.
- Use [City SDK](/en/city-sdk-docs) when you want to build services, model providers, billing, usage, or admin surfaces.
- Use [Agent Powers Docs](/en/powers-docs) when you want to understand built-in power behavior or author power integrations.
- Use [Products Docs](/en/docs) when you want product-facing usage for Downcity CLI, Downcity Console, or Chrome Extension.
## Recommended Order
First understand the model:
1. [How Downcity Fits Together](/en/docs/concepts/project-logic-overview)
2. [Agent Overview](/en/docs/agent/overview/what-is-agent)
3. [Configuration Model](/en/docs/configuration/overview)
Then choose the practical surface:
- Local CLI usage: [Downcity CLI](/en/docs/reference/cli)
- Project agent workflows: [Agent CLI product docs](/en/docs/reference/cli)
- Power workflows: [Power CLI product docs](/en/docs/reference/cli)
- Federation / local Agent commands: [CLI](/en/city-sdk-docs/packages/cli)
- SDK integration: [Agent SDK](/en/agent-sdk-docs)
## What This Space Explains
Downcity docs explain why the system is split into Downcity, City, Agent, powers, services, permissions, and configuration layers.
Concrete command flags, examples, and step-by-step operation guides live in product or SDK documentation.
### Desktop messages
Source: https://downcity.ai/en/docs/desktop/
{/* User-facing guide to file previews and Desktop Agent messages. */}
## Test System One decision models
Open **Settings → Models**, select JEV or another model in the decision model section, and use **Test decision model** in its details. A ready-to-run example is prefilled. Edit the content and decision instructions, choose Boolean, Choice, or Score, then select **Run test**. Choice accepts one distinct option per line (2–255 options); Score requires a minimum and maximum with a range width of 1–9. Update the instructions to match the selected answer type.
The result shows structured answers, any returned probability or option distribution, usage when provided, and elapsed time. Failures appear in the same form so you can correct the input and retry. Tests use the current account and the model's normal billing. Closing the details does not cancel a request already submitted.
## City Remote
Open **Settings → General → City Remote** to enable HTTP access, initially bound to `127.0.0.1:5314`. The switch applies immediately; save changes after editing the address or port. The page shows the actual running status, connection URL or startup failure. Copy the running URL into Chrome settings; there is no need to run `city on` separately.
Create a City Token on the same page, choosing its name, allowed Agents (or explicitly all existing and future Agents), and expiry. The creation dialog offers 30 days, 90 days, no expiry or a custom date. Plaintext is shown once in a separate dialog: copy it into Chrome's access credential and select Done. Existing Tokens can be edited or revoked. CLI `city token` manages the same records.
HTTP is disabled by default. Enabled settings are restored on Desktop startup. Disabling access, changing listen settings or exiting Desktop disconnects clients. Desktop and CLI retain the single-host model. Loopback is local-only; when binding another address, clients must use the corresponding machine address and network configuration.
## Send follow-ups and take over
When you send a Chat message, it is submitted immediately only while the session is idle and the pending list is empty. While a Turn is still running, or while the pending list already holds messages, new input is appended to the end of that list and submitted one at a time as each Turn finishes, without overtaking what is already queued.
Items in the list are local records until they are submitted: you can edit, delete, reorder, or pause them individually. `Option/Alt + Command/Ctrl + Enter` appends a paused item that never sends automatically; `Command/Ctrl + Shift + Enter` bypasses the whole list and submits the current input immediately without disturbing other queued messages. A rejected submission stays in the list as paused so you can fix and retry it.
Each Turn is owned by its initiating actor. The same user can send follow-ups. When another actor owns the work, a queued submission is rejected and stays in the pending list as paused rather than being discarded. To take over, stop the current work, wait for it to finish stopping, then send your message. Stop targets the Turn shown in the interface; refresh and retry if it has changed. A rejected submission does not mark existing work as failed.
Previously registered callbacks still deliver the stopped work's original result, never the answer from your new Turn.
## Answer questions and approve operations
Pending Agent questions and approvals appear above the chat input in both one-to-one and group chats. The card has a subtle background and rounded corners. Use the left and right arrows to switch between pending items; group items identify the requesting member. Switching pending items or collapsing tool details in a message preserves your input. For multiple questions, use the left and right arrows to revise an answer. Expand the optional note when needed.
Long content scrolls inside the pending area while the chat input stays visible. Successful submissions leave the pending area; failures display an error and preserve your input for retry. Tool details show execution information without repeating approval cards or approval results. Pending interactions are handled above the input, without a separate right-side response panel.
## Compact context manually
In an existing one-to-one Session, type `/` in the composer and select `/compact` to fold older stable conversation content into a context summary. The original chat history stays available. You can submit this while the session is running. Compaction joins the Session queue and runs in queue order at a maintenance checkpoint. Compaction and prompts share the Session queue's busy state. The session loading indicator remains active until the queue drains, including across consecutive commands. The chat timeline shows progress and the result, including when there is nothing to compact. New conversation drafts do not show this command.
Summary history uses plain text: user and Agent bodies plus tool results, without call arguments, call IDs, protocol states, or JSON wrappers. Structured results use indented key-value text to preserve field meanings. Compaction shares model-visible message conversion with regular conversations, excluding UI data, activity records, and reasoning. Attachments retain references and metadata without reloading binary contents. Nested image data URLs and explicitly named base64 fields in tool results are replaced with media type and size metadata. Sibling page text, paths, and URLs stay available, and the original tool records remain unchanged. It targets roughly the older 65% of stable projected content at the nearest complete-content boundary, while retaining at least 10,000 tokens of original history. Retention takes priority over the ratio. History at or below this amount, or without an eligible complete unit, is not compacted. Retention excludes system instructions, the previous summary, and tool definitions. Each compaction sends the previous summary, each selected history record, and the final summary instructions as separate plain-text messages in one request. Records keep their original order instead of forming one oversized user message; the summary is not repeatedly rewritten in batches. There is no fixed 16k or 64k input cap. Known model windows reserve 4,000 output tokens and 2,048 safety tokens; only the selected history range is reduced if needed. Unknown windows do not introduce an arbitrary input cap. The current model interface has no tokenizer: ASCII text, code, and JSON are estimated at roughly three characters per token, with other characters estimated by code point. Long encoded strings use a higher token-density estimate rather than the ordinary ASCII rate. These are estimates rather than exact token counts. Each summary has a 4,000-token output limit.
The new summary and boundary are saved once after successful generation. Failure leaves the previous summary and boundary unchanged. Transport interruption retries the same request at most once, discarding any unsaved partial summary. HTTP 400 and other request rejections are reported directly rather than assumed to be context-limit errors. Updated Federation services include the upstream status, an available request ID, and a redacted, length-limited response body to help identify the rejection reason.
## Window and panels
The Sidebar, main view, and right-hand BayBar share the window. Resizing the window preserves your Sidebar width where space allows, while the main view grows or shrinks first. When space runs short, BayBar narrows and then hides automatically. If the main view still lacks reading space, the Sidebar hides automatically too. At default width and 100% UI scale, it hides below a window width of 860px and returns at 908px. Your Sidebar width and UI scale adjust these thresholds. Closed panels release their full layout width.
Drag the boundary between a panel and the main view to adjust its width; releasing the boundary saves your choice. Temporarily shrinking the window does not overwrite saved widths. Automatically hidden panels return once there is enough space; manually closed panels stay closed. Explicitly opening BayBar or following a file link in a narrow window displays it over the right-hand content area. Resizing the window returns it to the available-space layout.
When the Sidebar is automatically hidden or manually closed, hover over the left window edge or its top toggle for about 150ms to reveal floating navigation. It overlays the main view without changing the main view's position or width. Moving away hides it after about 250ms; related menus, resizing, and keyboard interaction within navigation keep it open. Click the top toggle to pin it open. If space is insufficient, it stays floating until you click outside or press `Escape`.
Use the top window controls or `⌘/Ctrl+B` to toggle the Sidebar and `⌘/Ctrl+L` to toggle BayBar. Window resizing and panel dragging follow your movement directly. Panel toggles use a short animation, omitted when reduced motion is enabled.
## Session hierarchy
Within each Workspace in the Works sidebar, child Sessions appear directly below their parent and are indented by depth. Root Sessions and siblings are ordered by live status first, then by most recent update. If a parent is pinned or outside the current Workspace, its children remain visible as root Sessions. The pinned section keeps its manual pin order. Archiving a parent also archives all descendant Sessions. Finish or stop any running or queued work in the subtree before archiving.
Each parent shows up to 3 direct child Sessions by default. A fourth “Show more (N left)” row expands the remaining children; “Collapse child sessions” at the end returns to 3. Each nested parent expands independently. Workspace pagination counts root Sessions, and range selection includes only Sessions currently displayed.
## Pin conversations
In the Works sidebar, open a conversation's more menu and choose **Pin to top**. The conversation moves into a **Pinned** section above every Workspace, and stays visible when Workspaces are collapsed. Pinned conversations keep their pin order, newest first, and each row shows the Workspace it belongs to. The chat header menu offers the same action as **Unpin**. Pinning applies to Works sidebar conversations, including Group conversations.
## Share messages and conversations
Shared images and PDFs include the Downcity logo and name at the start of the document, in both preview and saved files.
Open the more menu below a completed Agent reply and choose **Share**, then **Save as image**, **Save as PDF**, or **Save as Markdown** in the preview to save its full content, including the Agent name and timestamp.
Choose **Share** from the Chat Session's top-right menu, then **Save as image**, **Save as PDF**, or **Save as Markdown**. Exports read the complete history and preserve the original order of user messages and Agent replies. Reasoning, tool activity, and internal context are excluded. Markdown keeps editable text and attachment links. Images and PDFs preserve code blocks, tables, math, images, and static views of built-in visualizations. Third-party or unavailable visualizations use their published text summary.
Sharing opens a preview with format tabs at the top, a centered preview canvas, and a fixed bottom bar for the content option, copying, and saving. Images scale proportionally to the available width. Long images keep all sections in one continuous scroll.
When sharing a single Agent reply, enable **Include previous user message** to place the nearest preceding user message above it, including sender, timestamp, text, and attachments. This option is off by default and appears only when a previous user message exists. Changing it updates the preview and content saved in every format. Messages from earlier history are supported without manually loading the chat first. PNG shows the generated image sections, with a **Copy image** button in the bottom bar that copies the section most visible in the preview to the clipboard without saving a file first. PDF shows the paginated file, and Markdown shows the source to be saved. Click the selected format's **Save as…** button to open the system save dialog. Cancelling that dialog keeps the preview open; closing the preview releases its prepared content. Resource failures appear in the preview.
Short images use a file save dialog. Long content asks for a directory and creates a new folder containing numbered PNG sections. PDFs paginate automatically and keep selectable text. After saving, you can open the file or image folder directly. Cancelling does not create an export. Image resource failures show an error so you can fix the resource and retry.
## Messages and files
Click a workspace file link in a conversation to preview it in the right-hand file panel while keeping the conversation visible. Files in other registered workspaces also open in this panel. Links with a line number jump to that line. Web links open in your default browser.
The file panel and Workspace view select a preview by file type. Markdown renders as a document; local `.html` and `.htm` files render as static web pages with relative stylesheets, fonts, and images. Scripts and in-page navigation are disabled. CSV and TSV files display as tables with row and column numbers, preserving quoted separators and line breaks. Tables display up to 1,000 rows and 100 columns. Use **Source mode** in the file menu to read the complete original text for Markdown, HTML, CSV, or TSV; text links with line numbers open in source mode.
Relative image paths in Markdown files, such as `images/chart.svg` or `../images/chart.png`, resolve from the current document's directory in both BayBar and Workspace previews.
Fenced Markdown code blocks labeled `mermaid` render as diagrams and adapt their layout to the available width. Diagrams follow the current theme. Copy their source or open the fullscreen view to zoom, pan, fit to the window, and download a PNG with the current theme. Incomplete streaming syntax keeps the last successful diagram visible; if parsing still fails after generation finishes, the source and a retry button appear.
Fenced Markdown code blocks labeled `svg` render as images that fit the available width, in both BayBar file previews and chat messages. Incomplete streaming content, invalid SVG, or image load failures display the source instead. SVG loads in an isolated image context without executing scripts.
PNG, JPEG, GIF, WebP, SVG, AVIF, APNG, BMP, and ICO files display as images. Switch between fitting the panel and viewing the original size. Images load separately from the text reader. Text previews remain limited to 2 MB; code and configuration files display as source.
Select text in the right-hand BayBar file panel to show a floating **Quote** button near the selection. Clicking it adds a reference to the current chat input with the workspace, file path, and selected text; source mode also includes the selected line range. The reference preserves the content at the time of quoting and can be saved with the draft or removed before sending. Clearing the selection, scrolling, switching files, or updating the file hides the button. Markdown, table previews, and source mode support text quoting. Switch HTML previews to source mode to quote text; images do not support text selection references.
Agent messages use two columns. The left column stacks the avatar and optional collapse button. The right column shows an info bar with the Agent name and timestamp, followed by the message body and file changes, with spacing between the info bar and body. Message actions such as copy, quote, and fork appear below all content, including the file changes card when present.
Messages with execution history have an arrow button below the avatar in the left column. The button stays visible and gains a subtle background on hover. Its arrow points right when collapsed and down when expanded. Click it to toggle the history, or use the keyboard.
History starts expanded while the Agent is working and collapses when the message finishes. Collapsing hides the history and keeps the final answer fully visible, without a tool summary row. If a message only contains execution history, the arrow remains available to reopen it.
Both actions use the button as a scroll anchor. Once you toggle it manually, that message keeps your choice. Messages without execution history display the answer directly without a collapse button.
### Chrome Extension
Source: https://downcity.ai/en/docs/products/chrome-extension/
The Downcity Chrome extension lets you chat with your Agent in a side panel and reference the current page or selected text. It requires Chrome 116 or later and a running, reachable Downcity City.
[Download Chrome Extension ZIP](/download/chrome-extension)
The extension is currently distributed as a ZIP for manual installation. It is not yet listed in the Chrome Web Store. The package works with Chrome on macOS, Windows and Linux.
## Install manually
1. Download and unzip the package into a permanent folder, such as `Documents/Downcity/chrome-extension`.
2. Open `chrome://extensions` in Chrome.
3. Enable **Developer mode**.
4. Click **Load unpacked**.
5. Select the extracted folder that directly contains `manifest.json`.
6. Pin Downcity in the toolbar and click its icon to open the side panel.
Select the extracted folder, not the ZIP. Keep that folder after installing.
## Connect Downcity
Start your City, open the extension's **Settings**, and enter:
| Field | Value |
| --- | --- |
| City URL | City HTTP URL, default `http://127.0.0.1:5314`; reverse proxy path prefixes are supported |
| Access credential | Bearer token accepted by your City, as required by the server |
| Default Agent | Select an accessible Agent after connecting to City; no manual ID entry |
| Default Ask | Default request used by the page sharing entry point |
Run `city token` on the machine hosting City to create a credential interactively, selecting the allowed Agents and expiry policy. For scripts, use `city token create --name chrome --agents --never-expires`. Enter the plaintext shown at creation directly in extension settings. Old Agent Tokens are no longer accepted.
Click **Connect City / refresh Agent list** (连接 City / 刷新 Agent 列表), select a default Agent, then click **Save and check** (保存并检查). An empty list means the credentials have no visible Agents or City has none. Once connected, reopen the side panel or click **Reconnect** (重新连接). The extension interface currently uses Chinese labels.
Port `15314` is for RPC, not HTTP. Do not include `/agents/...` in City URL: the extension adds the Agent scope. Chrome conversations use the `chrome` origin partition without a Workspace.
## Use the extension
- Click the toolbar icon or press `Command+I` on macOS / `Ctrl+I` on Windows and Linux. Change conflicting shortcuts at `chrome://extensions/shortcuts`.
- Enter your request. The composer references the current page by default; remove that reference to omit the initial page body. The Agent can still read the bound page on demand during this Turn.
- Select text on a regular webpage and click **Reference** (引用) to add it to the composer.
- Replies support Markdown, reasoning text and tool status.
- Reopening the panel restores the browser session for the same City and Agent.
- **New conversation** (新对话) creates another session without deleting server history.
- If the event stream disconnects, click **Reconnect** to load saved messages and reconnect.
Side-panel submissions provide a `chrome_page` Power for the current Turn. The Agent can call `read_page` or `find_text` on the tab and URL captured at submission. Navigation invalidates access. Clicking, filling and cross-tab operations are not exposed. Keep the panel open until the Turn completes; closing it or losing the execution connection interrupts caller-owned capabilities. Chrome internal pages, the Chrome Web Store and some protected pages cannot be read. Handle tool approval requests in Downcity when the extension shows a waiting status.
## Update or uninstall
Manually installed extensions do not update automatically. Download the new ZIP, replace the contents of the original folder, click **Reload** at `chrome://extensions`, then refresh webpages where you use references.
The current source build uses City, Agent and Token settings. Existing Workspace sessions are not migrated into Chrome sessions or deleted. Downloaded packages only include these features once released.
Click **Remove** at `chrome://extensions` to uninstall. Browser settings are removed; server sessions remain in Downcity.
## Verify a download
[Latest release metadata](/download/chrome-extension/latest.json) includes the version, size and SHA-256. The download redirects to a versioned ZIP; append `.sha256` to that final path to download its checksum file.
[Extension data and privacy](/en/docs/products/chrome-extension-privacy)
### Chrome Extension Data and Privacy
Source: https://downcity.ai/en/docs/products/chrome-extension-privacy/
This notice describes the current source build with request-level page Powers. Updated October 20, 2026.
## Data read and sent
The extension reads the active tab's title and URL to display page references. A local selection script detects selected text; clicking Reference passes that selection to the side panel.
When you submit a request, the extension sends your input and referenced page content, title, URL or selections to the Agent at your configured City URL. Side-panel submissions also provide page reading and text search capabilities for that Turn, bound to the tab and URL at submission. When the Agent calls an Action, its results are sent to your City. Removing a page reference omits the initial body but does not revoke on-demand reading for that Turn. Pages may contain private information visible after login; submit only on pages you intend to authorize the Agent to read. Navigation invalidates access, and closing the panel interrupts the execution channel.
Your City executes Agent requests according to its configuration. Content may be provided to the model services or tools you configure. Server retention, model providers and tool behavior depend on that City.
## Local storage
Chrome `storage.local` stores the City URL, Agent ID, Agent Token, default request, server session IDs, recent page-sharing records and temporary selection references. Tokens are not written to `storage.sync`.
Page-sharing records contain the page URL, request text and timestamp, not the captured page body. Uninstalling removes browser settings. Manage server sessions separately in Downcity.
## Permissions
| Permission | Purpose |
| --- | --- |
| `tabs` | Read active tab title and URL and respond to tab changes |
| `activeTab`, `scripting` | Read the bound page when you submit a reference or the Agent invokes a read/search Action during that Turn |
| `sidePanel` | Display persistent chat |
| `storage` | Save local settings, session identities and temporary references |
| HTTP/HTTPS host permissions | Connect to your chosen local or remote City and provide selection references on regular webpages |
The extension itself includes no advertising, analytics or separate telemetry uploads and does not sell user data. Website package downloads are handled by Downcity's website and Cloudflare as website requests.
For privacy questions, contact the maintainers through [Downcity GitHub Issues](https://github.com/genesiscosmos/downcity/issues). Do not include tokens or private page content in public reports.
### Website account
Source: https://downcity.ai/en/docs/website-account/
{/* User guide for the website account flow. */}
Select **Log in** in the website navigation and choose an available GitHub or Google login option. The original login dialog displays an authorization code and opens an authorization window. After provider authentication, enter the code from the original dialog on the confirmation page. Once confirmed, the original page continues automatically and returns you to the page you were browsing.
If the authorization window does not open, allow pop-ups for the Downcity website and try again. You can cancel while waiting. If login expires or fails, choose a login option again to retry.
Once logged in, select your account name in the navigation and choose **Account** to view your name, email, and user ID. If your provider does not supply an email address, the page shows “Not provided”.
## Balance and activity
The account center has a sidebar with **Overview**, **Usage & activity**, **Transactions**, **API Keys**, **Request history**, **Models & setup**, and **Profile**. On mobile, use the section menu at the top. The `view` URL parameter supports direct links; sections share loaded data, and refreshing preserves the current view.
The account center shows:
- Available Credits, permanent Credits, and temporary Credits with expiration dates.
- Month-to-date spend, AI request count, and metered Tokens.
- A daily trend chart with separate Spend (USD), Tokens and Requests views for the last 7, 30 or 90 days. Hover for daily details; dates before coverage remain unknown.
- An activity heatmap for the past 365 days, colored by AI requests. Hover or focus with the keyboard for details, click to pin a day, then close the details or press Escape to unpin.
- The latest three transactions in Overview, and up to twenty in Transactions. Select a record to expand its description and status.
Daily statistics use your browser timezone. AI requests include failed or cancelled executions; Tokens include only measured usage. Spend comes from independent billing records and is not estimated from Tokens. Dates before data coverage are not treated as zero activity.
Credits added may come from purchases or grants. The website currently links to Credits information and does not provide a direct top-up flow. If a service is unavailable, the affected section shows an error. Select **Refresh** to try again.
## API key management
Select **API Keys** in your account center, or open [key management](/account?view=keys). View your own keys, create new ones, edit names, model / route permissions, expiration and spending soft limits, or disable, reactivate and permanently revoke keys.
Permission choices come from the service’s published language model and route catalog. Selecting all current and future entries also grants access to entries published later; selecting none blocks all inference. Access to a route does not grant direct access to its model. Previously granted entries that are no longer available remain in the editor and can be removed. If the catalog is unavailable, existing permissions are preserved; refresh it before changing permissions or creating a key.
Enter spending limits in whole Credits. Blank means no limit; zero blocks new calls. The limit checks successfully settled lifetime spending for that key. Editing the limit does not reset spending. **No funds are reserved**: concurrent in-flight requests can exceed the limit, and all keys share your account balance. Enter expiration in your browser’s local time, or leave it blank for no automatic expiration.
The complete key is **shown only once** after creation. Copy and store it securely. Closing the dialog, leaving the Keys section or signing out removes it from the page; it cannot be retrieved again. If clipboard access is unavailable, select the text and copy manually. If creation is interrupted, refresh the list to check the result first. Revoke any newly created key whose secret you did not receive before creating another.
Disabled keys can be reactivated. Revocation is permanent; check the name and prefix before confirming. Disabling, revoking or expiration blocks new calls, while in-flight calls still settle. A key grants only permitted inference access and cannot sign you in or manage your account. See the [model relay guide](/en/city-sdk-docs/service/ai/relay) for OpenAI client configuration.
## Request history
Open [Request history](/account?view=requests) to view your own relay requests, with up to 20 records per page, filtering by key, previous/older pages, and refresh-latest controls. Key names and prefixes come from your own key list. If unavailable, records show key IDs; signed-in calls are identified separately.
Each record shows the requested public model / route, execution state, input/output tokens, settled Credits, duration and settlement state. **Unknown / not provided does not mean zero.** Input tokens are summed only when both cached and uncached input are known; reasoning tokens are part of output and are not added again. Pending, failed or missing charges are not presented as free. Select **Details & attempts** for request ID, resolved public model, metering breakdown and safe upstream attempt metadata: channel ID, actual upstream model, HTTP status and first-content latency. No prompts, answers or provider credentials are displayed.
Only key filtering is available; backend date, state and model filters are not provided. Requests rejected before execution may have no record. Interrupted requests may remain running, so consult metering and settlement separately.
## Models and setup
[Models & setup](/account?view=models) reads the live public language model and route catalog, with search by name or ID. It preserves backend context windows, reasoning efforts and structured pricing, including currency, units, scale and conditions. Missing fields are marked as unavailable. Tools or vision support is not inferred from tags, and prices are not converted into Credits.
The OpenAI Base URL comes from the website server’s Federation connection configuration. Choose an entry and protocol to copy a curl example for **OpenAI Chat Completions** or the **native Downcity model stream**. Examples use only `YOUR_DOWNCITY_API_KEY`; the page never reads or saves a real key. Replace the placeholder in your secure client. Native streaming uses its own Downcity SSE events. Responses and Anthropic Messages are not supported.
Catalog visibility does not grant a calling key permission: authorize the entry in key management. Catalog failures provide retry controls, and any retained older catalog is explicitly identified.
Select **Log out** to revoke the current website login and end the session in this browser. If logout fails, retry when the service is available. Log in again when your session expires. Logging out of the website does not log you out of the desktop app or other devices.
### Agent
Source: https://downcity.ai/en/docs/agent/overview/
The **Agent SDK** (`@downcity/agent`) is the core runtime for executing agent work. An Agent owns
identity, model, instructions, Tools, and one `AgentSessions` collection. Each Session may receive a
Workspace. `City` owns Workspaces, unique Power instances, Agent/Group collections, and transports.
## What an Agent does
- Reads project config (constructor options or a project directory)
- Maintains sessions and conversation history
- Calls tools during execution
- Uses Power capabilities projected by City into the current scope
- Binds to a model for inference
## Creating an Agent
Selecting “Create Agent” opens a dedicated creation page. Describe the role in natural language to let the system default model draft its name, description, instructions, and recommended powers, or choose manual configuration to start with an empty draft. AI output remains editable and nothing is persisted until you confirm creation.
The editor preselects the system default text model and only falls back when it is unavailable. Desktop surfaces display Agent names throughout the UI; stable IDs remain internal routing and storage references.
The name and description identify the Agent in contacts and conversations. The generated ID remains the stable reference used by local directories, APIs, and runtime ownership. If the generated ID already exists, Desktop asks for another name instead of silently adding a random suffix.
You can edit the name and description later without changing the stable Agent ID. Desktop also supports permanent deletion of the Agent definition, avatar, sessions, logs, schedules, and Agent-scoped power data. An Agent cannot be deleted while it is running or still belongs to a Group.
## Desktop completion notifications
When an Agent Session finishes a Turn in the background, Desktop shows a blue unread dot on the Agent and on the corresponding Session in the lower conversation list of the Chat Sidebar. The system app icon also shows the number of unread Sessions. No unread item is created while you are actively viewing that Session; opening it clears both the dots and the system badge. Multiple completed Turns in the same Session are grouped into one unread item.
## Setting a Desktop Agent avatar
When creating an Agent, Desktop randomly selects an image from its built-in pixel avatar pool and saves it as that Agent's independent avatar. Click the avatar on the Agent details page to select another built-in avatar at random or upload a custom PNG, JPEG, or WebP image up to 2 MiB.
## Creating a Group
Selecting “Create Group” opens a dedicated creation page. Describe the collaboration goal to let the selected model draft a Group name, collaboration instructions, and recommended members from the existing Agents, or start with manual configuration. AI output remains an editable draft until you confirm it.
You only provide the visible Group name. Desktop derives the stable internal `group_id` from that name. The name, instructions, model, and members remain editable later without changing the stable ID. The Group avatar is composed from its member Agent avatars.
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const agent = new Agent({
id: "repo-helper",
model: myModel,
tools: { my_tool: myTool },
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
## Key concepts
- **City** — owns Workspaces, unique Power instances, Agent/Group collections, and unified transports.
- **Session** — one execution thread. The Agent creates sessions on demand and routes messages through them.
- **Tool** — a function the Agent can call during execution. Passed directly to the constructor.
- **Power** — an extension capability provided and owned by City, then projected to every Agent.
- **Model** — the inference backend. The Agent holds the default instance, and a Session can override it with its own instance.
## What an Agent is not responsible for
- Managing multiple projects (that is the CLI / Console layer).
- Owning the model catalog (that is City / Federation).
- Persisting global credentials (that is City).
Keeping these boundaries clear makes it easy to answer: where should I configure a model? Where should a bot credential live? Is this a control-plane failure or a project-runtime failure?
## Execution model
The Agent execution model is simple:
1. Receive a message or task
2. Load the session context (history, tools, powers, prompts)
3. Call the model
4. If the model asks for a tool, execute it and continue
5. Return the result
Continue with:
- [Agent SDK](/en/docs/agent/sdk)
- [SDK architecture](/en/docs/agent/sdk-architecture)
- [Runtime architecture](/en/docs/agent/runtime-architecture)
- [Agent Lifecycle](/en/docs/agent/lifecycle)
### Agent SDK
Source: https://downcity.ai/en/docs/agent/sdk/
Use `@downcity/agent` to create an Agent, manage Sessions, and execute model calls in the same process. See the [Agent SDK docs](/en/agent-sdk-docs) for the complete API.
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
env: { API_KEY: process.env.API_KEY ?? "" },
});
const agent = new Agent({
id: "repo-helper",
model,
tools: { search: search_tool },
});
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Inspect this project" });
const result = await turn.finished;
await agent.dispose();
```
`Workspace` comes from `@downcity/city` and owns project files, search tools, Env, and an optional Shell. It does not own Agent identity, storage, Powers, or Session semantics. `agent.sessions.create({ workspace })` creates the SessionStore inside the Agent scope supplied by City Storage.
An Agent is not configured with a Workspace. Each Session may use a Workspace, and multiple Agents may operate on the same physical directory by creating separate Workspace instances. `agent.dispose()` releases Agent resources and the Sessions' Workspace resources.
Local runtime state is centralized outside the project directory:
```text
~/.downcity/agents//
├── sessions///
└── archived-sessions///
```
`chat` and `work` are the only two categories, and the category is the only field that decides the
directory. `type` is the role inside that category. Omitting `origin` defaults to
`{ category: "work", type: "work" }`. Custom roles must declare a category, and restoring a Session
uses `get(session_id, category)`. See
[Session Origin and Types](../agent-sdk-docs/sessions/origin-and-types) for details.
City starts shared Power instances and background work when it binds them. Session execution waits for initialization automatically. Use `await city.close()` to close the host or `city.agents.remove()` to remove one Agent. City also supplies HTTP and RPC.
## Streaming messages
`subscribe()` returns the unified live Session Mutation stream:
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
process.stdout.write(mutation.delta);
}
});
```
Tool lifecycle updates use `type: "tool"`; a pending Interaction lives on its Tool Part's `interactions` while that Tool is `waiting-user`. Submit responses through the Session command API:
Part order within an Assistant step always follows the model stream. If Tool execution becomes ready before its Tool Part reaches the stream, execution waits at that boundary instead of overtaking preceding text. Waits are isolated by `tool_call_id`, so multiple Tools can still execute concurrently. Clients should render Mutations and history snapshots in their recorded order without reordering them.
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
async function decide_approval(interaction_id: string, decision: "approved" | "denied") {
await session.respond({
interaction_id,
response: { type: "approval", outcome: decision === "approved" ? "resolved" : "denied", payload: { decision } },
});
}
```
Use `session.messages()` for a canonical history snapshot. It paginates on Message boundaries and returns a `next_before_sequence` cursor when older history exists. After a disconnect, reload the snapshot and establish a new subscription.
Hosts that render a flat activity feed can use the SDK projection instead of depending on `.downcity` file names:
```ts
import { to_session_message_timeline_events } from "@downcity/agent";
const page = await session.messages();
const timeline = page.items.flatMap(to_session_message_timeline_events);
```
Session JSONL, logs, and scheduled actions use the StorageProvider scope injected by City. If no City storage is injected, the Agent uses an in-memory provider. Project File/Search tools cannot access this private state. Applications should use Agent.sessions and Session APIs rather than depend on the physical layout.
Continue with:
- [SDK architecture](/en/docs/agent/sdk-architecture)
- [Runtime architecture](/en/docs/agent/runtime-architecture)
- [Complete Agent SDK API docs](/en/agent-sdk-docs)
### Agent SDK Architecture
Source: https://downcity.ai/en/docs/agent/sdk-architecture/
Downcity separates the execution subject, project resources, and Power runtime. `Agent` owns identity,
instruction, model, custom Tools, and Sessions. `Workspace` owns project resources and Shell. `City`
owns unique Power instances, lifecycle, and Agent/Group collections.
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Shell, Workspace } from "@downcity/city";
const agent = new Agent({ id, model, instruction });
const workspace = new Workspace({ id: "project", path, shell });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
```
## Responsibility model
```mermaid
flowchart TD
Host["Host application"] --> City["City
resource and lifecycle container"]
City --> Agent["Agent
identity, model, instruction, Sessions"]
City --> Workspace["Workspace
project resources and Shell"]
City --> Powers["Powers
unique instances, configuration projection, lifecycle"]
Agent --> Enter["agent.sessions.create({ workspace })"]
Workspace --> Enter
Enter --> Sessions["AgentSessions
Session collection"]
Sessions --> Session["Session
continuous execution"]
Session --> Tools["Workspace + Agent + Power tools"]
Session --> Context["City-projected PowerContext"]
Powers --> Context
City --> Storage["StorageProvider
Memory by default"]
Session --> Storage
Workspace --> Files["Project File/Search"]
Workspace --> Env["Workspace Env"]
Workspace --> Shell["Shell"]
Shell --> Sandbox["Sandbox Provider + Workspace Sandbox"]
```
The dependency direction stays explicit:
- `@downcity/agent` depends only on neutral resource protocols from `@downcity/type`.
- `@downcity/city` depends on Agent and injects Workspace, Tools, and `SessionHooks` through its internal binding; Agent does not depend back on City.
- Hosts choose a Sandbox Provider when constructing each Shell; City does not understand the backend.
- Edge adapters use `@downcity/type/workspace` without loading Node.js local implementations.
## Agent is the subject
An Agent is reusable across projects. It is never configured with one permanent Workspace.
```ts
const agent = new Agent({
id: "repo-helper",
model,
instruction: "Maintain the current project.",
tools: { company_search: company_search_tool },
});
city.agents.add(agent);
const frontend = await agent.sessions.create({ workspace: frontend_workspace });
const backend = await agent.sessions.create({ workspace: backend_workspace });
```
Agent does not own Powers. City owns one instance per Power ID and projects every Power into an
Agent/Workspace execution entry. Every Power Action receives the current `PowerContext` with
directly callable restricted Agent, Workspace, Session, and Turn handles. Powers use one City-level
`initialize/dispose` lifecycle; Agent/Workspace only defines the current call scope.
## Workspace is the resource boundary
`Workspace` owns:
- A stable Workspace ID and normalized project path.
- Rooted project file access and File/Search tools.
- Workspace environment variables.
- An optional Shell and its Sandbox adapter.
- It never owns Agent or Session runtime storage. City provides the domain-neutral `StorageProvider`.
Workspace does not create SessionStore, understand Agent IDs, register Powers, call models, or
create Storage. The constructor has no runtime data-root option.
Shell belongs to Workspace because command execution operates on Workspace resources. The public
entry is:
```ts
import { Shell, Workspace } from "@downcity/city";
```
There is no separate `@downcity/shell` package. Platform Sandbox packages remain independently
installable and are injected into Shell.
## Session is the execution boundary
`agent.sessions.create({ workspace })` combines Agent and Workspace for one Session without merging ownership. Agent, Session, and the City execution projection provide:
- The final Tool set from Workspace tools, Agent custom tools, and Power bridge tools.
- `AgentSessions` and the Agent domain `LocalSessionStore`.
- Agent/Workspace/Session/Turn-aware PowerContext and an execution lease.
- Logs, scheduled Power Actions, and Shell binding.
Tool name conflicts fail during composition; no source silently overwrites another. The same Agent
can hold multiple Sessions with different Workspace resources. Workspace resources may be registered
in City or managed by the host outside City.
## Private storage
When City receives a local persistent Storage Provider, runtime state is centralized outside the project:
```text
~/.downcity/
└── agents/
└── /
├── sessions/
├── powers/
├── logs/
└── schedule.jsonl
```
City Storage is a bottom-level file/database capability. Agent code interprets the
`agents//` business scope and creates Session and Power stores inside it. Project
File/Search tools use a different rooted FileSystem, so they cannot inspect or change Session
history, Power state, or runtime data.
## Environment and checkpoints
Environment variables describe the project execution environment and therefore belong to
Workspace:
```text
/.env < WorkspaceOptions.env
```
`set_env()` and `patch_env()` update the Workspace snapshot and synchronize Shell. Existing
Sessions commit the new environment at the next Step checkpoint, avoiding context changes halfway
through a model call.
Power registry changes, Session model changes, and compaction follow the same checkpoint rule.
## Lifecycle
```mermaid
sequenceDiagram
participant Host
participant Agent
participant Workspace
participant City
Host->>Agent: new Agent(options)
Host->>Workspace: new Workspace(options)
Host->>City: city.agents.add(agent)
Host->>Agent: sessions.create({ workspace })
Agent->>Workspace: use current resource
Agent->>City: use injected Storage
Host->>Agent: dispose()
City->>Workspace: dispose registered resources
City->>Agent: detach runtime resources
```
`city.close()` stops registered Agents and Sessions, disposes registered Workspace resources,
closes the City Storage Provider, and then closes HTTP/RPC transports. A Workspace managed outside
City remains owned by its host.
## Package boundaries
| Package | Owns | Does not own |
| --- | --- | --- |
| `@downcity/type` | Neutral cross-package resource protocols | Concrete resources and lifecycle implementations |
| `@downcity/agent` | Agent, AgentSessions, Session, Store, SessionHooks, model Tool Loop | City composition, Power lifecycle, project resource implementation, platform Sandbox |
| `@downcity/city` | City, Workspace/Shell implementations, Power SDK/runtime, Hook scheduling, RemoteAgent, HTTP/RPC transports, local host data | Agent execution internals, concrete built-in Powers |
| Sandbox packages | Native process isolation for one platform | Workspace, Agent, Session, or Power business behavior |
Continue with [Runtime architecture](/en/docs/agent/runtime-architecture).
### Agent Runtime Architecture
Source: https://downcity.ai/en/docs/agent/runtime-architecture/
The runtime is designed to execute every input in a deterministic order, expose intermediate model and Tool state, and recover Sessions after process interruption.
## Session is the execution boundary
Each Session owns independent:
- ID, metadata, and model override.
- Message history and System Snapshot.
- Prompt/Command queue.
- Tool approval state.
- Composer and Executor.
- Live Mutation stream.
```ts
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Inspect and fix the project" });
const result = await turn.finished;
```
`prompt()` returns a Turn Handle instead of direct text. `finished` resolves for both success and failure; inspect `result.success` and `result.error` for the outcome.
## How one Prompt runs
```mermaid
sequenceDiagram
participant App as Host
participant Session
participant Queue as SessionQueue
participant Messages as SessionMessages
participant Composer
participant Executor
participant Model as LanguageModel
participant Tool
App->>Session: prompt(query)
Session->>Queue: enqueue Prompt
Queue->>Messages: persist User Message
Queue->>Executor: create stable run context
Executor->>Composer: compose system, history, tools
Executor->>Model: run model Step
Model-->>Executor: text / reasoning / tool chunks
Executor->>Messages: write Assistant draft and Mutations
Executor->>Tool: execute Tool Call
Tool-->>Executor: Tool Result
Executor->>Model: continue Tool Loop
Executor->>Messages: commit final Assistant Message
Executor-->>App: turn.finished
```
The runtime guarantees:
1. The User Message is persisted before the model call.
2. Streaming Assistant output is first written as a recoverable draft.
3. Text, Reasoning, Tool Calls, and Tool Results preserve canonical model-stream order.
4. The Assistant Message enters Active history only after completion.
5. An interrupted run can recover from the draft and Action state.
## State checkpoints
Only one execution loop is active per Session. Every input that can affect the next Step enters one ordered queue:
- User Prompts.
- Workspace environment updates.
- Power registry updates.
- Session model updates.
- Explicit compaction.
Changes are committed at Step boundaries, never halfway through a model call or Tool execution. An active Step uses the stable execution view created for it.
Model resolution order is:
```text
Session model > Agent model
```
Agent and Session hold model instances supplied by the host. The SDK does not select or restore model services from a string model ID.
## Composer and Executor
`SessionComposer` owns model-context policy: combining system blocks, translating history, supplying Tools, creating compaction plans, and identifying context-limit errors.
`Executor` owns one run: preventing concurrent execution within a Session, creating the run context, calling Composer, driving the model and Tool Loop, and handling aborts, retries, and context-limit recovery.
Neither object knows the physical Session storage path, the Agent list, or HTTP/RPC transports.
## System Snapshot
A Session supports three System semantics:
- Default: use the current Agent identity, Agent instruction, Power system, and Session context at execution time.
- `snapshot()`: persist the current complete system for this Session.
- `syncshot()`: regenerate from current Agent state and replace an existing snapshot.
`system()` returns the effective structured System Snapshot. Updating the Agent instruction does not unconditionally overwrite a Session that has already been snapshotted.
## Messages and persistence
Local state lives under the Agent-specific Workspace scope in the user-level Downcity directory:
```text
~/.downcity/agents//
├─ sessions//
│ ├─ instruction.md
│ ├─ meta.json
│ └─ messages/
│ ├─ active.jsonl
│ ├─ agent_message.json
│ └─ segments/*.jsonl
├─ archived-sessions/
├─ logs/
├─ schedule.jsonl
└─ sandbox/
```
- `active.jsonl` stores history since the last compaction.
- `agent_message.json` stores the latest complete streaming Assistant draft.
- `segments` stores immutable compacted history prefixes.
- `instruction.md` stores an explicitly persisted System Snapshot.
Messages use monotonically increasing `sequence` values; streaming updates to the same Message use increasing `revision` values. JSON files are atomically replaced with a temporary file, `fsync`, and `rename`; cross-process writes are serialized through Workspace locks.
Hosts should read structured state through `session.messages()`, `get_info()`, and `system()` instead of depending on these physical file names.
## Live observation
`subscribe()` emits Mutations that occur after subscription:
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
process.stdout.write(mutation.delta);
}
});
```
Mutations cover complete Messages, Assistant Parts, text/reasoning deltas, Turn state, and Session state. After a disconnect, reload the canonical snapshot with `messages()` before establishing a new subscription.
## Power runtime
Powers belong to City, not Agent or Workspace. City owns one instance per Power ID and projects its Actions, System text, Hooks, Resolve Points, and HTTP routes into every Agent/Workspace execution entry.
PowerRegistry projection changes take effect at Session checkpoints. A Power initialization failure isolates that Power without blocking others. Agent disposal stops Sessions and ActionSchedule; City closes the Power `initialize/dispose` lifecycle.
ActionSchedule is a local delayed Power Action scheduler owned by the Agent. Events are stored in the Agent's storage scope; after restart, abandoned running entries recover to pending. It is not a distributed scheduler and provides no cross-machine leader election.
## City Powers
Every Agent receives three City-owned powers. They are the single entry point for "what only the harness knows" plus the generation models City itself holds.
```jsonc
{ "action": "sandbox.explain_path", "args": { "path": "~/.downcity/powers/messenger" } }
```
| Power | Action groups | Answers |
| --- | --- | --- |
| `context` | `env`, `sandbox`, `workspaces`, `usage` | Which Agent, Session, Workspace, model, timezone and local date this turn runs under; which sandbox backend and instance this Session runs in, which host directories are mounted, and why a path is blocked; every Workspace registered in this City; user-level token usage |
| `ai` | `image`, `sound` | Generate images; transcribe audio and synthesize speech |
| `agent` | `agent` | Every Agent registered in this City |
An action id is `group.action`, independent of which power owns it: `env.get` belongs to `context`, and both `image.*` and `sound.*` belong to `ai`. Calling a power with no arguments lists its actions; nothing here returns credential values, and each action declares `read` or `write` so you can see which ones consume quota or change state before calling them.
Availability follows the same rule as Powers: everything City registers is available to every Agent, with no per-Agent gating and no configuration file.
### Image and sound
Both use the models available to your Federation account, so there is no separate configuration and nothing to set up. Because there is no configured default, pass `model` explicitly or take it from `image.models` / `sound.models`; `asr` and `tts` fall back to the first suitable model when none is given.
Image generation is a two-step job: `create` returns a `job_id`, `result` reads it. Creating an image consumes quota, so the model must confirm with you first. `result` writes the generated images into the power's private directory and returns their local paths; render them with ``.
Automatic transcription of inbound voice messages belongs to the Messenger power, which calls the `ai` power's `sound.asr` when a channel delivers a voice or audio attachment. If transcription fails for one attachment, the message still arrives.
## Security model
Security is enforced at distinct capability boundaries:
| Capability | Enforced boundary | Permission semantics |
| --- | --- | --- |
| File/Search Tools | Rooted LocalFileSystem | May actively access only the Workspace |
| Sandbox Shell | Workspace Sandbox | Commands run in the Workspace's isolated, persistent environment |
| Host Shell | Approval gateway | The user explicitly authorizes one host command with a recorded reason |
| Power | Power business authorization | Workspace does not own all Power resource access |
| City Power | Declared per action | Answers runtime facts and runs declared `read` actions; `write` actions consume quota or write files under the Agent's directory |
| Custom Tool | Registering host | Agent Core does not infer business permissions |
Explicit attachments may use an absolute path selected by the host because they are declared Prompt inputs. This does not expand where model-driven File/Search Tools may browse.
## Cross-platform strategy
Node.js already normalizes files, paths, networking, JSON, timers, AbortController, and basic child processes. Agent, Session, Store, and Workspace therefore contain no operating-system branches.
Isolation differences are contained by the neutral Sandbox Provider protocol. Each Shell receives its Provider explicitly and owns the isolated, persistent Sandbox created when it binds a Workspace. City only manages Workspace resources and does not know whether their Shell uses a local container, microVM, remote host, or another backend. The default implementation uses microsandbox microVMs.
## Lifecycle
```mermaid
flowchart LR
Agent["new Agent"] --> Register["City registers Agent"]
Power["City registers Power"] --> Initialize["Power initialize"]
Register --> Running["execute Sessions"]
Initialize --> Running
Running --> Close["city.close()"]
Close --> Sessions["stop Agent Sessions"]
Sessions --> Dispose["Power dispose"]
Dispose --> Workspace["release Workspace / Shell"]
```
Sessions wait for the Agent's City execution binding before execution. `agent.dispose()` does not stop City Power instances. `city.close()` closes Transports, Agents, Powers, Workspaces, and Shells after active Hook/Action leases finish.
Continue with [Agent Lifecycle](/en/docs/agent/lifecycle).
### Agent Lifecycle
Source: https://downcity.ai/en/docs/agent/lifecycle/
`new Agent(options)` creates only the Agent identity, model, instructions, custom Tools, Session collection, and ActionSchedule. Powers do not belong to Agent. After City registers the Agent, it projects City-owned Power capabilities into the Agent execution entry.
Run only one active runtime for the same Agent ID in the same physical Workspace. ActionSchedule is a local scheduler and does not coordinate duplicate Agent processes.
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const shell = new Shell({ sandbox_provider: new MicrosandboxProvider() });
const agent = new Agent({
id: "my-agent",
model,
});
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell,
});
const city = new City({ agents: [agent], workspaces: [workspace] });
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Start" });
await turn.finished;
await city.close();
```
Sessions wait until the Agent's City execution binding is ready. `agent.dispose()` releases only Agent-owned Sessions, ActionSchedule, and execution bindings. `city.close()` first stops Agent Sessions, then stops the single City-owned Power instances and closes Workspaces, Shells, and Transports.
Continue with the [Agent SDK docs](/en/agent-sdk-docs).
### Power
Source: https://downcity.ai/en/docs/power/overview/
A **Power** is Downcity's single extension product unit. City provides and owns it, projecting runtime capabilities into Agent/Session scopes while also supporting host management logic, a Sidebar + Mainview workspace, and Config.
```text
Power
├── main optional: City instances, lifecycle, management logic, and actions
└── renderer optional: Sidebar + Mainview and/or Config
```
## What a power does
- Exposes tools the Agent can call during execution
- Hooks into City Power lifecycle (`initialize`, `dispose`)
- Manages its own state, connections, and background tasks
- Provides a Sidebar + Mainview workspace and edits configuration in a separate Config on its Catalog detail page
- Runs inside the City process boundary, not as a separate service
Each Power ID maps to one instance and one configuration inside a City. The host persists Config and
projects an immutable `PowerContext.config` snapshot for each execution. Agents own neither Powers,
configuration, nor lifecycle. Powers model multiple accounts in their own Config instead of using a
generic named-configuration layer.
## Built-in powers
Downcity ships several built-in powers that cover common agent needs:
- **Chat** — connects messaging channels (Telegram, Feishu, QQ, etc.)
- **Task** — schedules and runs background tasks
- **Memory** — persists conversation history and context
- **Shell** — executes local commands in a sandboxed environment
- **Schedule** — cron-style job scheduling
- **Web** — browses the web and extracts content
- **Skill** — composes reusable tool chains
## Writing a power
In the SDK, provide a Power instance directly without using the install protocol:
```ts
const city = new City({ powers: [github_power], workspaces: [workspace] });
city.agents.add(agent);
```
Add a `power.json` with a unified `main` and optional `renderer` entry only when the Power must be installed and managed by CLI or Desktop.
## Where to learn more
- [Agent Powers Docs](/en/powers-docs) — built-in power manuals and examples
- [Agent SDK](/en/agent-sdk-docs) — how to register and use powers programmatically
- [Custom Power](/en/docs/power/custom) — the unified main install protocol
### Messenger Power
Source: https://downcity.ai/en/docs/power/messenger/
Messenger Power is a channel runtime owned by City. It does not belong to an individual Agent and does not expose long-lived state through `context.agent.chat`.
## Desktop workflow
Open Messengers to see every Bot Account in a dedicated Sidebar. Use the plus button in the Sidebar header and choose Telegram, Feishu/Lark, or QQ from the dropdown to create a Bot Account. You can add multiple bots for the same provider. Feishu/Lark defaults to scan sign-in, so you never have to paste an App ID or App Secret.
Selecting an Account lets you manage:
- enabled state, credentials, connection tests, and reconnects
- the default Agent for new Conversations
- Access requests and grants for external identities
- each Conversation's Agent, Channel Session, and paused state
- Inbox and Outbox items that exceeded automatic retry limits
- Activity records that exclude message bodies and secrets
Desktop applies Account saves immediately. It restarts only that Account's Connector, so Desktop, City, and other Bot Accounts do not need to restart. Credentials can be entered in Settings. Stored secrets are never echoed; leave a secret field empty to preserve its current value.
:::callout
Telegram and similar platforms may require a proxy in some networks. As soon as a proxy is enabled at the system or Desktop level, channels use it automatically.
### Network and connectivity
Telegram, Feishu, and QQ requests all go through one shared power egress layer, so behavior is identical:
- Every request has an overall timeout, so a connection can never hang forever. On failure the Account moves to `Error` and shows the reason directly.
- Proxy priority: the Desktop network proxy setting, then the operating system proxy, then `HTTPS_PROXY` and related variables from `Global / Env`. On macOS the power inherits the system proxy automatically, and system bypass lists are converted into `NO_PROXY`.
- When the proxy changes (including toggling the system proxy or a VPN), Desktop re-resolves it within 30 seconds and reconnects enabled channels. No restart is needed.
- Only `http://` or `https://` proxy URLs are supported. A `socks5://` URL fails immediately with an actionable message. `NO_PROXY` supports host names, `.example.com`, `*.example.com`, and IPv4 CIDR.
- After an unexpected disconnect the Account reconnects with capped backoff (at most once every 5 minutes) and resets on success. While an Account is disconnected, Outbox deliveries are only delayed, and do not consume retry attempts.
If an Account stays in `Connecting`, click Test connection once. If it still fails, the error text shown in the UI names the concrete cause, such as an unusable proxy or an unreachable endpoint.
### Agent attachments
Agents output attachments as `reports/daily.md`, with a file path relative to the current Workspace inside the tag. Desktop chat displays attachments in message order: documents have a file link, `photo` displays an image, and `audio`, `voice`, and `video` display playback controls. Local media uses Desktop's controlled file protocol; file links use the existing chat file navigation.
Tags in code blocks, inline code, or escaped text remain examples. An incomplete streaming tag stays readable and becomes an attachment after it closes. Do not place Markdown links or `http://filename` inside a tag: these display a format error with the original tag. File links remain available when a media preview cannot load. Unified attachment cards show the file type and name; hover over a truncated name to see the full path. Invalid tags are available under “Show details”.
## Account configuration
Account definitions and credentials have one source of truth: City Power Config.
```json
{
"accounts": [
{
"account_id": "support-telegram",
"name": "Support Bot",
"provider": "telegram",
"enabled": true,
"agent_id": "support",
"bot_token": ""
}
]
}
```
Feishu/Lark accounts use `app_id`, `app_secret`, and optional `domain`. QQ accounts use `app_id`, `app_secret`, and optional `sandbox`.
Adding a Feishu/Lark Channel in Desktop defaults to scan sign-in. Click Scan to create, scan the QR code with Feishu, and confirm on your phone; the App ID and App Secret are written automatically and the Account starts immediately, so nothing has to be copied from the Open Platform. The confirmation page is pre-filled with an app name and description, and pre-selects the scopes this channel needs together with the `im.message.receive_v1` event.
Scan sign-in uses the OAuth 2.0 Device Authorization Grant (RFC 8628) and can only create a new app, so it never overwrites an existing app's configuration. QR code lifetime follows the platform response (currently about one hour), and you can regenerate it after it expires. When scanning is not possible, such as when the organization has not enabled developer access or you want to reuse an existing app, switch to Manual credentials on the same page.
## Messages and Sessions
An allowed external message is persisted to Inbox, then routed through a Conversation to its own Channel Session (`category = "chat"`, `type = "channel"`). Each chat and thread under a Bot Account has a separate Session that subsequent messages reuse. Histories are separate from other chats and from the Agent's main conversation. Session Messages remain the only complete conversation history.
IM routing binds only an Agent and requires no Workspace selection. In Desktop, Channel Sessions and the Agent's main conversation default to the same main Workspace (`agents//main`), with the same Workspace tools, Powers, custom Agent tools, and Session management tools. Histories remain separate. Files and Shell use the current Workspace; Session management tools can create project Work Sessions. SDK hosts supply the default main context through the Agent's `workspace` option. An unconfigured Agent is selected automatically only when City has exactly one Agent.
Changing a Conversation's Agent or resetting it creates a new Channel Session and preserves the previous Session. Existing IM Work Sessions are also preserved during upgrade; chats continue in newly created Channel Sessions. Received attachments live in Messenger storage; outgoing attachments may use that storage and the Session's current project directory.
```text
Platform → Access → Inbox → Conversation → Channel Session
→ Outbox → Platform
```
Inbox deduplicates provider message IDs and prompts Session with a stable `request_id`. If the process exits after the User Message is committed, recovery does not append a second User Message. Agent results use stable Delivery IDs when entering Outbox, preventing duplicate jobs during recovery.
Outbox provides persistence, leases, bounded retries, and manual recovery. When a provider has no idempotent send protocol, external delivery is at-least-once: a crash after provider acceptance but before local acknowledgement can cause a repeated send.
## Agent Actions
An Agent can only access Conversations it owns through strict actions:
- `context`: read the current Chat Session route
- `list`: list Conversations owned by the current Agent
- `history`: read canonical Agent Session messages
- `send`: persist text to the reliable Outbox
- `react`: persist a Telegram message reaction to the reliable Outbox
## SDK assembly
```ts
import { MessengerPower } from "@downcity/powers/messenger";
const city = new City({
powers: [new MessengerPower()],
workspaces: [workspace],
power_host,
});
```
The City host provides Power Config, lifecycle Storage, and Agent Session capabilities. Connectors are restored during `initialize()` and closed together during `dispose()`.
## Feishu dependency
A host that enables a Feishu/Lark Account must install:
```bash
npm install @larksuiteoapi/node-sdk@^1.73.3
```
Scan sign-in relies on the SDK's `registerApp` and requires `^1.67.0` or later. On older versions the scan entry reports that an upgrade is needed, while manual credentials keep working.
## Related documentation
- [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle)
- [Built-ins Overview](/en/powers-docs/builtins/overview)
### Task Power
Source: https://downcity.ai/en/docs/power/task/
{/* User guide for Todo and Schedule shells; execution stays with the existing City TaskPower. */}
Pick a view — **List, Board, Calendar, or Gantt** — at the top of the task Sidebar, then select a task from the list below it. The main area shows the chosen view; selecting a task replaces it with that task's body and execution history, and a back button appears in the header. Task bodies no longer take a BayBar tab.
Search, filters, and sorting sit in the main area header and apply to the current view's set. The Sidebar list groups tasks by **Todo, Daily, and Unlabeled** first, then into Unfinished and Completed within each kind, ranking the ones waiting on you first and marking review, retry, or failed preparation on the row. Unlabeled holds tasks that have not chosen a kind yet; pick Todo or Daily in the task body and the task moves to that group. Use the main area search to find a task rather than scrolling the Sidebar. Board, Calendar, and Gantt hide the filter controls because they do not use them.
To create a task, describe what you want in the bottom input. Generation validates, saves, and applies the arrangement directly, then displays a summary without another confirmation. A compact table has headers for task name, type, status, executor, timing, and actions. Narrow windows scroll horizontally without hiding columns. Only the name or the explicit Open body menu item enters the task body. Type shows an explanation, status opens available actions, and executor and timing use field editors. Unavailable completion toggles explain why without navigating. These filters do not restrict what you can request in the bottom input; no task-type selection is required.
The leading circle toggles personal one-time work between completed and unfinished. Agent results still require review before acceptance; recurring enable state is separate from completion. Each row's `···` menu offers applicable actions such as adjusting requirements in the bottom input, start or retry, open body, reopen, pause or enable recurrence, and delete with confirmation. Active execution restricts editing, deletion, and duplicate starts. Failed field saves retain edits; successful changes apply directly to the task.
Default sorting puts unfinished work first, ordered by its nearest execution or planned time, then undated work, with closed tasks last. Click Name, Type, Status, Executor, or Time headers to toggle ascending/descending order; arrows show the direction. Time sorting uses actual instants and the next calculable recurring trigger, keeping undated, paused, or unpredictable recurrence last in either direction. Creation time and task ID break ties consistently. Choose Restore default sorting from the filter menu to reset.
## Visual hierarchy
Task uses complete card blocks: the list has separate Unfinished and Completed blocks with counts and headers, retaining the current sort within each group; calendar and Gantt each form one block; individual board tasks and detail information, progress, and results form their own blocks. Only complete blocks receive a shared background, radius, and padding. Toolbars, column headings, sidebar section labels, and the input wrapper stay transparent; ChatInput itself is a block. No decorative outlines or shadows are used. Hover, selection, focus feedback, and subdued timing guides remain. Month view has explicit date grid lines. Popovers use an opaque theme background so content underneath cannot show through.
## Views
Switch between **List, Board, Calendar, and Gantt** at the top of the task Sidebar; tasks are listed below it as **Todo, Daily, and Unlabeled**, and the main area shows either the chosen view or the selected task's body. The bottom input stays available in the views. Choosing another view leaves the task body first, returning to a plain view.
Search, filters, and sorting live in the main area header and apply to the current view's set.
Board groups tasks into Not started, In progress, Needs attention, and Completed. Recurring tasks show their enable state separately. Card titles open details, timing uses field editors, and each card shares the list's action menu. Completing an occurrence does not end recurrence. Dragging between columns is not supported.
Calendar offers day, week, and month layouts. Day/week use hourly grids, duration-based blocks, and side-by-side overlap placement. Date-only deadlines and multi-day ranges appear in the all-day section. Today shows a current-time line. Time points use compact blocks without inventing a saved duration.
Calendar fills the main content area with a fixed toolbar and date header, an independently scrolling time grid, and continuous multi-day bars in the all-day section. Click an event for a summary with executor, timing, and behavior; Edit time expands the form, and only Open details enters BayBar. One-time plans support drag rescheduling while preserving duration. Existing same-day ranges have a bottom resize handle, and the preview supports start/end editing. Recurring and deadline events are read-only in this interaction. Time changes are validated, saved, and synchronized directly. Click an empty half-hour slot to supply timing context to the bottom ChatInput without opening a creation form. The grid uses the device timezone; recurrence is calculated in the task timezone. Advanced Cron rules that cannot be expanded are listed explicitly.
Gantt shows a navigable two-week range with a fixed task column, sticky date header, and today line. Click an out-of-range direction hint to locate its date. Planned ranges appear as bars and time points as separate diamond markers. Recurring tasks show their next calculable trigger. Undated tasks, paused recurrence, and advanced rules without a prediction remain accessible below the timeline. Gantt is currently read-only, without drag editing or dependency links.
## Todo
Open the task page and describe your request in the ChatInput fixed at the bottom of the page. The agent interprets the goal, executor, and timing, then produces an arrangement preview. No type selection or new-task form is required.
Todo represents one-time work, with no time, a specific time, or a planned time range. A requested time can trigger one reminder or execution. A planned range does not imply continuous execution, and a deadline does not start an agent. Complete personal todos from the list and undo that completion. For agent results, complete the todo or request revisions.
If saving succeeds but starting fails, the form reports that the todo is saved but not started. Retry starting the same object instead of creating another todo.
## Schedule
Schedule represents recurring work based on Cron and a timezone. A weekly research report generates a Schedule; a reminder tomorrow morning generates a one-time Todo. Use the same ChatInput; successful generation directly applies the timing and executor.
Recurring tasks show a time summary and an enable switch. Detailed settings are available under More. Pausing does not stop active work, and a successful run does not end recurrence.
Personal reminders create an actionable reminder without an agent run. Planned ranges use planned_at and planned_end_at; the end must follow the start and does not imply repeated execution. Automatic triggers require City to remain running. Missed one-time Todos need a user decision; recurring work missed while offline is not replayed in a burst.
## Creation APIs
Use `task.create()` for structured task fields: it validates and saves without an internal model. Prefer it when the chatting Agent already knows the title, instructions, and timing. Use `task.generate()` for natural-language input from the Task page or an Agent; both share the same generation action.
Pass `text` to create, or `task_id` with changes to revise the same task. Generation waits for validation and saving and returns the complete task. Reuse `submission_id` for uncertain new submissions. Successful generation applies the arrangement without confirmation. Recording work does not start it; explicit immediate requests start execution and timed work waits for its trigger. Manual `create` work starts through `run`. Model failures retain the task identity for retry. Shared time validation checks formatting, timezone, and past triggers; relative dates use the current generation time.
## Input and saving
The flow is request, generate and save, then inspect the arrangement or result. A summary above the input shows the work, executor, and timing. Continue typing to revise the same task. Recording, immediate execution, and scheduled execution follow the request without a confirmation button. Startup or scheduler errors remain visible alongside the saved task.
One ChatInput stays visible while the list scrolls independently. The input identifies the task being revised; New task returns to creation while preserving the original task and draft. Requirements expand on demand and the send bar remains visible. Type `@` to select an Agent, Enter to submit, and Shift+Enter for a new line. Generation failures preserve task identity for retry.
Todo or Schedule follows the complete request, independently of list filters. A deadline does not trigger execution. Titles may repeat or change while identities stay stable.
## Task body and history
Select a Sidebar row or a task name in a view to replace the main area with that task's body and execution history; the back button in the header returns to the previous view. Generation failures provide adjustment and retry actions in the body. Requirements collapse by default while running or viewing a result so progress and delivery take priority. Executor, type, and status share the board's presentation. Properties are under More, and history expands on demand.
Tasks are not split into separate blocks by internal state or type. Older tasks remain in the same list with their identities and history intact. Reading a notification does not complete or accept work.
## Results and recovery
References share a compact block across the composer, queue, and sent messages. Task deliveries show the task title and result summary; messages, files, and code selections use distinct icons and source information. Click to expand the complete context. Editable references have a right-side removal action; read-only messages do not. Blocks have no shadows or decorative borders, and presentation never truncates the context sent to the model.
Delivery results provide two actions. Copy task reference writes structured HTML and readable plain text. Pasting into ChatInput restores the reference block; other apps can read the source and full output. Reference blocks have a Copy reference action, and selecting a reference in the editor supports system copy. Plain-text paste and paste inside code blocks remain text. Continue task opens a workspace new conversation for the task's Agent and Workspace, prefilling ChatInput with a structured delivery reference without sending. No existing conversation needs to be open. Existing new-conversation draft content is preserved; the Session is created on send through the normal application workflow. Missing execution targets are reported locally rather than silently replaced.
Results and history stay with the original object. One-time agent results require acceptance; recurring results follow the configured per-run acceptance setting.
Request revisions and choose Submit changes to save feedback and start revision in one interaction. If starting fails after saving, feedback remains available and Start revisions retries execution without resubmitting it. Previous results remain available. Feedback on a recurring result does not automatically change its standing instructions.
Retry failed execution separately from retrying AI organization. If an arrangement is saved but synchronization fails, retry synchronization without recreating work or calling the model again.
After a City restart, interrupted work is retained for review rather than silently replayed. Wait for active execution to finish before editing its definition or deleting it. Tool approvals remain in the execution session.
Chat-created work retains its original delivery session even if the executor changes. Explicitly instruct the executing agent to use Messenger when external delivery is required.
Continue with [Messenger Power](/docs/power/messenger) or [Agent Powers Docs](/powers-docs).
### Memory Power
Source: https://downcity.ai/en/docs/power/memory/
Each Downcity platform data root has one local User independent of Federation login accounts. City owns base user information; MemoryPower owns User and Agent memories.
## Per-turn context
Initial system assembly or syncshot → load both Profiles → freeze with Instruction → process messages and optional tools.
User Profile applies across local Agents and Sessions; Agent Profile applies to one Agent. Identity and Instruction share one Agent system message, immediately followed by a separate Agent Profile message. User Profile is an independent system message before Session information. Both Profiles are frozen with Instruction and never enter user content. File updates affect new Sessions; existing Sessions require explicit syncshot and otherwise keep their original system. Historical preferences do not define the current task or override current requests and permissions. There is no Jev gate or periodic Session extraction.
## Saving
```json
{"target":"user","mode":"profile","content":"Before the main answer, remind me to speak English and briefly correct my expressions."}
```
remember.target accepts user, agent and current_workspace. mode accepts profile or knowledge, defaulting to knowledge. Profile supports User/Agent; Workspace supports knowledge only. User writes need no login or identity callback. The Agent decides when to call tools and confirms saving only after success.
## Storage and maintenance
`user/user.json` holds base information, `user/PROFILE.md` holds global agreements, and `agents//PROFILE.md` holds Agent-specific agreements. User knowledge is stored once and shared across local Agents through search/read.
Both Profile scopes allow unlimited appends and are checked independently once daily. Documents above 2,000 Unicode characters are optimized using profile_model_id with exact-match, non-overlap and version validation plus backups. Without a model, saving and loading still work.
Host management APIs are available; a dedicated Desktop interface is pending. Switching service accounts does not switch local memories. Channel authorization remains the host's responsibility.
- [Memory Power reference](/en/powers-docs/builtins/memory)
### MCP Servers
Source: https://downcity.ai/en/docs/power/mcp/
MCP is managed by the built-in Skill Power. Its unified Sidebar lists individual resources under Skills and MCP; selecting a server shows status, tools, and errors. Manage server definitions in Skill Config. Existing server definitions and OAuth credentials remain in the global MCP storage directory and are shared across Agents and Workspaces.
Enable a server for an Agent in **Agent Profile → Skills**. Access requires an explicit assignment, stored as a JSON array of server IDs at `~/.downcity/agents//mcp-servers.json`. A missing file grants no access. Global server enablement alone does not grant access; removing an assignment preserves the shared definition and credentials. Host actions `skill.mcp.assign` and `skill.mcp.unassign` accept `{ agent_id, server_id }` and are not exposed to the model.
## Configure a server
Each server uses one file at `~/.downcity/powers/mcp/servers/.toml` by default. The filename is its server ID. If the host uses a different MCP config path, the `servers` directory is beside that config file: `dirname(config.path)/servers`.
For an already installed local stdio server:
```toml
title = "Filesystem"
enabled = true
command = "mcp-server-filesystem"
args = ["/absolute/allowed/directory"]
cwd = "/absolute/working/directory"
env = { LOG_LEVEL = "info" }
request_timeout_ms = 60000
max_result_chars = 100000
```
Replace the command and arguments with those required by your server. For a Streamable HTTP endpoint:
```toml
title = "Remote MCP"
url = "https://mcp.example.com/mcp"
headers = { "X-Client" = "downcity" }
```
Specify exactly one non-empty `command` or `url`. `args`, `cwd`, and `env` belong to local programs; `headers` belongs to HTTP endpoints. There are no `source`, `install`, or `transport` sections. `title` is optional. Omitted `enabled` defaults to `true`; optional `request_timeout_ms` and `max_result_chars` default to `60000` milliseconds and `100000` characters. Set `cwd` explicitly for project-specific servers: a shared connection does not inherit the caller's Workspace. `${NAME}` in arguments, working directories, URLs, and headers reads from the host environment.
The Desktop editor edits the actual server file. Use explicit Save or Delete for each server. File changes are checked against the original text (`expected_content`); if another edit has changed the file, the operation is rejected to prevent stale content from overwriting it. Reread the file before saving again.
Initialization, definition reads, and single-file saves only scan and validate definitions; they do not start or connect to a Server. After editing files outside Desktop, reread definitions in Config to discover the changes without executing a Server. Explicit Test, Mainview connection Refresh, or the first `tools`/`call` request connects to the Server. Refreshing a resource reconnects only the selected Server; Config's global Refresh reconnects all enabled Servers. Tool discovery reads every page of the tool list for both stdio and HTTP connections. Validation errors identify the affected file while valid servers remain available. Changing, disabling, or deleting a definition releases its affected connection; City shutdown closes all connections.
Loading or editing a definition does not install software. Known implicit-install runners such as `npx`, `bunx`, and `uvx`, along with package-manager install-and-run entry points, are rejected. Install the required program through the existing approval workflow first, then configure its installed executable. Existing `config.servers` collections are not read or automatically migrated; create individual server files explicitly.
## HTTP OAuth authorization
For HTTPS servers implementing MCP OAuth, a 401 starts SDK discovery, dynamic client registration and authorization code login with PKCE. Downcity opens the system browser and accepts the callback on `127.0.0.1`; the pending connection resumes after login. Test, connection Refresh, tool discovery/calls and explicit reauthorization can open the browser. Reading status or saving definitions never does.
```toml
title = "OAuth MCP"
url = "https://mcp.example.com/mcp"
auth = "oauth"
# Set a pre-registered public client ID when dynamic registration is unavailable:
# client_id = "your-client-id"
# client_name = "Downcity"
```
These fields belong at the TOML root. `auth` is optional: a standard 401 also triggers discovery. Pre-registered clients must accept loopback callbacks with dynamic ports. OAuth metadata, issuer, registration and token endpoints must use HTTPS. Static `headers` remain available for API keys; when an OAuth token is available it supplies Bearer authorization.
The MCP server detail view displays `none`, `authorizing`, `authorized` or `expired` and provides Reauthorize and Cancel authorization buttons. Servers connect independently, while operations for one server are serialized to avoid duplicate login windows. Authorization waits at most five minutes and responds to cancellation and City shutdown. Tool operations still honor `request_timeout_ms`, so reauthorization during a call may have a shorter remaining deadline.
Credentials are stored under `dirname(config.path)/auth/`, isolated by server ID, exact endpoint and issuer. Files use `0600` permissions and the directory uses `0700`. The plaintext records contain client information, tokens and expiry, never authorization codes, state or PKCE verifiers. Tokens are excluded from Agent status and diagnostic errors. The SDK refreshes refresh tokens; failed refresh can lead to login again. The SDK manages session headers, protocol negotiation and SSE. An explicit HTTP 404 allows one session rebuild and retry; ambiguous network failures never replay a tool call.
Servers must implement the required discovery and authorization protocol. Twinkle Cloud protocol compatibility, initial login, refresh and restart reuse have not been verified against its live service.
## Agent workflow
Agents use the Skill Power's `servers`, `tools`, and `call` actions. Servers are not registered as separate Powers, and external tools are discovered on demand. The `list` and `lookup` actions remain available for Skills.
```ts
skill({ action: "servers" });
skill({ action: "tools", args: { server_id: "filesystem" } });
skill({ action: "call", args: {
server_id: "filesystem",
tool_name: "read_file",
arguments: { path: "/allowed/example.txt" },
} });
```
Use `servers` to list servers assigned to the calling Agent and their status, including disabled or invalid definitions, `tools` to inspect original tool names and input JSON Schemas, then `call` to invoke a tool. Replace the example tool name and path with values supported and permitted by your server. MCP validates arguments locally before sending them. Unassigned, disabled, or invalid servers cannot be called. Assignment checks apply to both `tools` and `call`.
This structured discovery and invocation interface has no standalone shell executable. Server file management is available in Desktop; the MCP Agent interface is provided by `skill` through `servers`, `tools`, and `call`.
## Approval and limits
`call` is classified as write access and has `approval: false`; calls to enabled servers assigned to the calling Agent execute without an additional MCP approval. MCP provides no installation or uninstallation tools. Program installation and removal use the existing approved execution workflow. Server descriptions and results are external data, never Agent instructions. Calls support cancellation and the configured timeout; oversized results carry a truncation marker.
stdio servers execute local programs. Configure trusted servers explicitly and maintain credentials in the owning environment settings instead of sending plaintext through chat. Interactive MCP input requests are not supported.
The three Agent actions remain stable when server files change. Server Power registration and synchronization are unnecessary.
### Shell
Source: https://downcity.ai/en/docs/power/shell/
Shell is a Workspace capability, not a Power. It owns its Sandbox Provider, the isolated Workspace Sandbox, and reusable Shell Sessions.
## What it does
- Executes commands in the Workspace Sandbox by default
- Keeps installed tools and HOME state across Chat Sessions
- Captures stdout, stderr, and exit codes
- Supports timeouts and cancellation
## Safety
Shell has only two targets: `sandbox` and `host`. Sandbox execution is the default and cannot see arbitrary host paths. Host execution requires an explicit reason and host approval; Sandbox failures never fall back to the host.
## Use cases
- Running build scripts
- File system operations
- Querying system state
- Executing project-specific tools
Continue with:
- [Messenger Power](/en/docs/power/messenger)
- [Task Power](/en/docs/power/task)
- [Agent Powers Docs](/en/powers-docs)
### Custom Power
Source: https://downcity.ai/en/docs/power/custom/
One directory defines one Power. The Power ID is its install directory, City catalog key, and runtime identity. There is no separate Extension identity.
## Two optional entries
```text
Power
├── main provides City instances, lifecycle, management logic, and actions
└── renderer declares a Sidebar + Mainview workspace and/or Config
```
Each entry is optional, but at least one is required. A Power may provide only City runtime capabilities, only a Sidebar + Mainview workspace, only Config, or the complete experience.
Recommended development layout:
```text
github-power/
├── power.json
├── package.json
├── README.md
├── assets/icon.svg
├── src/
│ ├── power.ts
│ ├── main.ts
│ └── renderer.tsx
└── dist/
├── main.js
└── renderer.js
```
Both entries use ESM. `main.js` must be self-contained. `renderer.js` is a single-file browser bundle that default-exports a Renderer definition with an optional `sidebar + mainview` pair and an independent `config`. Downcity does not install dependencies or run build scripts.
## Direct SDK usage
SDK users can construct a `CityPowerRegistration` directly without `power.json`:
```ts
const registration = {
id: "github",
title: "GitHub",
description: "GitHub integration",
readme: "/absolute/path/README.md",
has_config: false,
has_sidebar: false,
has_mainview: false,
power: new GithubPower(),
};
const city = new City({
powers: [registration],
workspaces: [workspace],
});
city.agents.add(agent);
```
The following entry protocol is only needed when CLI or Desktop must install, discover, and manage the Power.
## Power definition
```json
{
"schema_version": 1,
"id": "github",
"version": "1.0.0",
"title": "GitHub",
"description": "GitHub integration",
"readme": "./README.md",
"icon": "./assets/icon.svg",
"main": "./dist/main.js",
"renderer": {
"entry": "./dist/renderer.js",
"sidebar": true,
"mainview": true,
"config": true
}
}
```
`renderer.sidebar` and `renderer.mainview` must both be `true` or both be `false`; `renderer.config` is independent. The actual default export must exactly match these three static capabilities. `package.json` must declare `"type": "module"`. `readme` must point to a `.md` user document inside the Power root. Desktop safely renders that file in the Power Overview; its name and subdirectory are up to the author, and it should describe purpose, configuration, and required credentials. The icon may be an HTTP(S) URL or a file relative to the Power root and is used in Power navigation and the Overview.
## Unified Power entry
The `main` file default-exports one Power instance. There is no separate `agent` entry or second
main lifecycle. During `initialize()`, `power.action()` registers Sidebar/Mainview Actions and
`power.config_action()` registers Config Actions:
```ts
import { Power, type PowerLifecycleContext } from "@downcity/city/power";
class GithubPower extends Power {
readonly name = "github";
readonly title = "GitHub";
readonly description = "GitHub integration";
initialize({ power, notifications }: PowerLifecycleContext) {
power.config_action({
id: "config.read",
run: async (_input, context) => {
const config = context.config.get();
return { api_url: config.api_url, token_configured: Boolean(config.token) };
},
});
power.config_action({
id: "config.save",
run: async (input, context) => {
const current = context.config.get();
const config = validate_and_restore_secret(input, current);
await context.config.set(config);
return { saved: true };
},
});
void notifications.publish({
topic_key: "sync:repository",
title: "Repository sync completed",
route: { repository: "downcity", view: "activity" },
});
}
}
export default new GithubPower();
```
One Power ID maps to one instance, one lifecycle, and one configuration per City. City injects the
unique Config store into Config actions, while the execution instance reads its current snapshot from
`PowerContext.config`.
`notifications.publish()` and `notifications.dismiss()` are always bound to the current Power. The host namespaces `topic_key` and treats `route` as a route inside that Power workspace, so a Power cannot impersonate another Power or navigate to arbitrary Desktop pages. Only the latest unread notification for the same `topic_key` is retained.
`PowerLifecycleContext.system` exposes only explicitly authorized City host capabilities. To append a background result to an existing Session, call `append_agent_session_message({ agent_id, workspace_id, session_id, category, text })`. All four identity fields must come from an explicit persisted target; a Power must not scan for or guess a Session.
For functional pages such as a Skill manager, `system.list_workspaces()` returns summaries of Workspaces registered by the host. A Power is trusted local Node.js code; it must validate action inputs and own its file, network, and long-lived resource behavior.
## Renderer entry
The Renderer declares UI slots in one bundle. `sidebar` and `mainview` form the Power workspace, must be declared together, share a host-owned JSON route, and cause the host to add a dynamic top-level navigation entry. `config` is independent from the workspace and appears only on the Power Catalog detail page. Optional `agent_config` contributes one tab to Agent Profile. These surfaces use host-provided UI Components:
```tsx
import { useEffect, useState } from "react";
import { define_power_renderer } from "@downcity/city/power/react";
export default define_power_renderer({ config: function GithubConfig({ config, ui }) {
const { Button, Group, Input, LoadingState, Page, Row, Toolbar } = ui.components;
const [config_state, set_config_state] = useState<{ api_url: string }>();
useEffect(() => {
void config.invoke<{ api_url: string }>("config.read").then(set_config_state);
}, [config]);
if (!config_state) return ;
return
void config.invoke("config.save", config_state).then(() => ui.toast({ type: "success", message: "Saved" }))}>Save} />
set_config_state({ ...config_state, api_url })} />} />
;
} });
```
The Renderer bundle must leave `react` and `react/jsx-runtime` as host externals mapped to `downcity-power://runtime/react` and `downcity-power://runtime/react/jsx-runtime`. Bundle other frontend dependencies into the single `renderer.js` file.
Desktop loads the Renderer in its host React tree. Sidebar and Mainview use `power.invoke()` for Power-level actions and coordinate through `navigation`; Config uses `config.invoke()` for the current Power's Config actions. Each slot receives only its required gateway and host UI—never the Desktop controller. Renderer is trusted local UI code, so install only trusted sources.
Sidebar and Mainview also receive a read-only `notifications` array scoped to the current Power. Each item includes the local `topic_key`, content, route, and creation time so navigation objects can render unread state. Navigating to the notification's exact route lets the host mark it read; the Renderer cannot publish or mutate notifications.
When a Sidebar needs a creation entry in its header, pass the host-provided `SidebarCreateMenu` through the `Sidebar` component's `actions` prop. The host owns the header, plus button, and dropdown presentation; the Power declares only the creation types and the business navigation for each selection.
Use `SidebarTreeItem` for a tree Sidebar: `depth` expresses hierarchy, `kind` distinguishes `branch` and `leaf`, parents own expansion through `expanded` and `on_toggle`, and `on_select` updates the Mainview through shared `navigation`. When `leading` is omitted, the host supplies consistent default icons for branches and leaves. A branch with no children can omit `on_toggle` and will not show a disclosure button. History and other deeper navigation should remain in this tree instead of consuming Mainview space. Use `ItemMenu` for row actions and the host-safe `Markdown` for Markdown content. After a mutation, call `ui.invalidate()`; the host increments the shared `ui.revision` for that Power across Sidebar, Mainview, Config, and Agent Profile tabs. Reload snapshots in place, retaining existing data and an open reader during background refresh; reset Agent-specific state when `agent_id` changes. Keep refresh state out of the route.
### Agent Profile tab
Declare `agent_config: { id, label, component }` to contribute one Agent Profile tab. `id` and `label` must be non-empty strings. The host collects declarations from enabled Powers and namespaces the tab ID by Power. Catalog refreshes preserve the selected tab and mounted component while the same renderer registration remains available. No additional `power.json` capability flag is required, and the slot can be declared independently of Sidebar, Mainview, and Config.
```tsx
import { define_power_renderer } from "@downcity/city/power/react";
import type { PowerAgentConfigComponentProps } from "@downcity/city/power/react";
function AgentSettings({ agent_id, ui }: PowerAgentConfigComponentProps) {
const { Page, Row } = ui.components;
return
;
}
export default define_power_renderer({
agent_config: { id: "settings", label: "Settings", component: AgentSettings },
});
```
The component receives exactly `{ agent_id, power, ui }`. `agent_id` identifies the Agent being configured. `power.invoke(action_id, input)` uses the owning Power's business action gateway, as in Mainview; it does not use the Config gateway. The Power validates the target Agent and action inputs. The slot receives no `navigation` or `notifications` props.
`PowerAgentConfigDefinition`, `PowerAgentConfigComponent`, and `PowerAgentConfigComponentProps` are exported from `@downcity/city/power/react`. Use `ui.components` for layout and `ui.invalidate()` after mutations; the host advances the Power's `ui.revision` so its surfaces can reload. The built-in Skill Power uses this generic slot for **Agent Profile → Skills**, including skill links and MCP assignments.
## Config interaction
```text
Open the Powers Catalog and select a Power
→ edit the Power's unique configuration in Config
→ save and let the Power refresh affected long-lived resources
```
Each Power ID has one Config, independent from Sidebar/Mainview. The host only persists it and does not interpret fields or generate a form from JSON Schema. Structure, validation, defaults, Secret behavior, and resource refresh after save belong to the Power.
Configuration is stored under `~/.downcity/powers//config.toml` with directory mode `0700` and file mode `0600`. The current store uses TOML, so values must be a TOML-compatible JSON object. `agent.json` stores no Power references; City automatically provides every Power and the same configuration to every Agent.
## Install
```bash
city power install ./github-power
# Configure GitHub on its detail page in Desktop Powers
city power config github --set '{"api_url":"https://api.github.com"}'
city power update github
city power uninstall github
```
The installer copies only `power.json`, `package.json`, the Markdown file declared by `readme`, runtime entries, and an optional local icon. It never runs dependency installation, build scripts, or any entry. Local paths cannot escape the Power root or use symlinks. Installed artifacts receive a SHA-256 integrity digest. Install only trusted sources.
Continue with:
- [CLI Commands](/en/docs/cli/commands)
- [Agent Powers Docs](/en/powers-docs)
### City
Source: https://downcity.ai/en/docs/city/overview/
**City** is Downcity's local Agent host container. It keeps Agent instances in memory and forwards HTTP/RPC requests to the selected Agent.
## What City owns
- **Agent collection** — `city.agents.add(agent)` admits an existing Agent, `city.agents.get(agent_id)` reads one, and `city.agents.list()` lists them.
- **HTTP/RPC forwarding** — City owns two transport ports and forwards requests by `agent_id`.
- **Host lifecycle** — CLI/Desktop create and dispose City, Agent, Workspace, and platform resources; City owns its server lifecycle.
## What City does not own
- Model catalogs, identity, usage, and billing, which belong to Federation/Embassy.
- Database adapters for Agent configuration and Workspace assembly, which belong to the host composition root.
- Concrete Power storage, which belongs to the selected Power Provider/Adapter.
These belong to the Agent runtime.
## How Agents connect to City
When you run `city on`, the CLI reads configuration from the global DB, explicitly creates every Agent, registers them with one `City`, and starts City's HTTP/RPC servers.
When you use the SDK directly, create an `Agent` yourself. Call `city.agents.add(agent)` when you need unified HTTP/RPC forwarding.
## Where to learn more
- [City SDK](/en/city-sdk-docs) — the SDK for building on City infrastructure
- [Payments](/en/payments) — accounts, balance, usage, and payment APIs
- [Agent SDK](/en/agent-sdk-docs) — how Agents consume City services at runtime
### Model Pool
Source: https://downcity.ai/en/docs/city/model-pool/
City owns the model catalog through the Federation AIService. Agents do not embed provider keys or model definitions directly. Instead, they bind to models by ID, and City resolves the ID to a live model instance at runtime.
## How it works
1. City registers model providers (OpenAI, Anthropic, Google, local models, etc.)
2. Each provider exposes capabilities and model IDs
3. The Federation AIService maintains a unified catalog
4. Agents bind to a model ID like `"default"` or `"quality"`
5. At runtime, the ID is resolved to a live model instance
## Model binding
For CLI-managed projects, the binding is stored in the agent config in the CLI global DB:
```json
{
"execution": {
"type": "api",
"modelId": "quality"
}
}
```
For SDK projects, the model is passed directly to the constructor or session.
## Provider management
City handles:
- Provider registration and authentication
- Model capability discovery
- Usage metering per provider
- Failover between providers
Continue with:
- [City Identity](/en/docs/city/identity)
- [Usage & Billing](/en/docs/city/usage-billing)
### Identity
Source: https://downcity.ai/en/docs/city/identity/
Federation issues user identities and stores account data. Clients access Federation directly
through Embassy. A product configures its trusted Bureau business endpoint only when it needs a
backend of its own.
## Identity Boundaries
- **Federation**: the server runtime that issues `user_token` credentials and manages accounts, Services, and Bureau records.
- **Embassy User**: holds the current `user_token` and performs user login and business requests.
- **Embassy Admin**: manages Federation with an Admin Session, including authorizing Federation to issue Bureau Tokens.
- **Bureau**: a product partition selected by `bureau_id`; its backend uses an independent Bureau client as its machine identity.
Embassy exposes only `user` and `admin` identity domains:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
});
```
Embassy is not bound to a Bureau. A login flow supplies `bureau_id` only when it needs a product partition.
## User Identity
Account flows live under `embassy.user.account`. After login, the same Embassy instance starts
using the new User Token immediately:
```ts
const providers = await embassy.user.account.providers();
await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: {
email: "user@example.com",
password: "password",
},
});
const current_user = await embassy.user.current();
const user_token = embassy.user.account.token();
```
Email login requires completed verification. The SDK automatically exchanges the completed login for a token. `await embassy.user.account.logout()` revokes the current token before clearing local credentials; failure retains it for retry. Bureau validates new protected requests online with Federation and rejects access when validation is unavailable; existing streams continue.
A User Token is a JWT signed by Federation with Ed25519. It carries `user_id`, `bureau_id`,
issuer, audience, and expiry claims. Public verification keys are available at:
```text
/.well-known/downcity.json
/.well-known/jwks.json
```
User AI, Payment, and regular Service requests go directly to Federation:
```ts
const models = await embassy.user.ai.catalog();
const accounts = embassy.user.service("accounts");
const services = await embassy.user.list_services();
```
When the current Bureau has a trusted `server_url`, relative-path requests are sent to that origin
with the current User Token. Embassy does not forward the token to another origin:
```ts
const result = await embassy.user.post(
"/reports/summary",
{ range: "today" },
);
```
## Administrator Identity
An administrator exchanges an ID and password for an expiring Admin Session. There is no Root
Admin Token:
```ts
await embassy.admin.login({
admin_id: "owner",
password: "password",
});
const current_admin = await embassy.admin.current();
const bureaus = await embassy.admin.bureaus.list();
```
If the password is lost, an operator with deployment access resets it with
`fed deploy --admin-reset`.
## Bureau Token
An authenticated Federation Admin can issue a Bureau Token:
```bash
fed bureau token
```
The CLI asks Federation to generate a high-entropy token. Federation stores only its `token_id`,
purpose, hash, and lifecycle status. The plaintext is returned once and belongs in the service environment:
```bash
DOWNCITY_FEDERATION_URL=https://fed.example.com
DOWNCITY_BUREAU_TOKEN=fb_br_xxx.secret
```
The business service verifies incoming Federation User Tokens through the independent `Bureau` client:
```ts
import { Bureau } from "@downcity/federation";
const bureau = new Bureau({
federation_url: process.env.DOWNCITY_FEDERATION_URL!,
bureau_token: process.env.DOWNCITY_BUREAU_TOKEN!,
});
const identity = await bureau.identify(request);
```
`bureau_token` is a long-lived, revocable opaque Bearer credential. It is not a JWT and cannot issue
User Tokens. It is bound to a `bureau_id` when created; `identify()` validates the signature,
issuer, audience, expiry, and that `bureau_id`. Revoke a leaked token immediately with
`fed bureau token revoke `.
Federation remains the sole User Token issuer. Account login and Admin management APIs are only
entry points into Federation's issuing flow; a Bureau Token cannot call the User Token issuing API.
| Credential | Holder | Purpose | Verification |
| --- | --- | --- | --- |
| `user_token` | User client | Access Federation and Bureau business APIs | Federation JWKS signature |
| `admin_token` | Administrator Embassy | Federation control plane | Federation Admin Session |
| `bureau_token` | Bureau business service | Prove machine identity and call `identify()` | Federation credential hash |
Continue reading:
- [Federation SDK](/en/city-sdk-docs/packages/federation)
- [Model Pool](/en/docs/city/model-pool)
- [Usage & Billing](/en/docs/city/usage-billing)
### Usage & Billing
Source: https://downcity.ai/en/docs/city/usage-billing/
City meters every model call and tracks usage across all connected Agents and products. This is the foundation for billing, quotas, and cost management.
## Usage tracking
- Every model request is metered (tokens in, tokens out)
- Usage is aggregated per user, per project, and per model
- Real-time usage dashboards are available in the Console
Federation administrators with an active Admin Session can read server-side aggregates:
```text
GET /v1/usage/admin/overview?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
GET /v1/usage/admin/users?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
GET /v1/usage/admin/retention?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
```
`overview` returns range-active users, true de-duplicated daily DAU/WAU/MAU series, outcomes, token
composition, Credits, model and action distributions, local-hour activity, and average/P50/P95
duration. `users` returns calls, input/output/total tokens, success rate, top model, Credits, duration,
and last activity per user. `retention` combines Accounts registration time with real AI Usage active
dates to calculate D1, D3, D7, D14, and D30 registration-cohort retention. Failed and cancelled calls
still represent real product activity, so they count toward activity and remain visible in outcomes.
## Balance
- Users maintain a balance account in City
- Balance is consumed as usage accrues
- Low balance triggers alerts
- Auto-recharge can be configured
## Billing
City integrates with Stripe for:
- One-time payments
- Subscription plans
- Usage-based billing
- Invoice generation
## Quotas
- Soft quotas warn before limits are reached
- Hard quotas block requests when exceeded
- Quotas can be configured per user, per project, or per model
Continue with:
- [Model Pool](/en/docs/city/model-pool)
- [City Identity](/en/docs/city/identity)
### Federation & Service
Source: https://downcity.ai/en/docs/federation/overview/
**Federation** is the shared server runtime that City connects to. It owns user identity, City isolation, Service routing, model catalogs, usage, and payments, while providing a unified interface over multiple AI providers.
## Federation AIService
The Federation AIService is a City component that:
- Registers model providers and their capabilities
- Exposes a unified model API regardless of the backend
- Tracks usage and routes requests to the appropriate provider
- Handles failover and rate limiting
An Agent binds to a model through an ID like `"default"` or `"quality"`. The Federation AIService resolves this ID to a live model instance at runtime.
## Service / Action infrastructure
City also provides a general-purpose **Service** and **Action** layer for building backend capabilities that Agents can call:
- **Service** — a long-running backend capability (e.g., a knowledge base, a search engine, a database)
- **Action** — a discrete operation that an Agent can invoke (e.g., send an email, query a database, call an external API)
Services and Actions are registered with City and can be discovered and called by any connected Agent.
## Relationship to City
`Federation` is the server and `City` is the user client carrying a `user_token`. When you use the City SDK, you are interacting with:
- City identity and auth
- Federation AIService (model catalog and routing)
- Service/Action infrastructure (backend capabilities)
- Usage, balance, and billing (City Services)
Federation generates an Ed25519 Key Ring on first startup and publishes verification keys through `/.well-known/jwks.json`. Embassy can connect to Federation directly; a product deploys Bureau only when it needs its own backend capabilities. A Bureau Token and the independent Bureau client let that service verify User Tokens locally. Federation does not create Bureau Tokens on startup.
## Administrator login and recovery
The first `fed deploy` interactively asks you to set a Federation administrator ID, password, and confirmation. The password must be at least 12 characters; Federation stores only a salted password digest. After starting `fed web`, enter those credentials on the login page. The browser never receives the remote administration Session Token, which remains in the local BFF process.
An ordinary deployment never changes the administrator. If the password is lost, only an operator with Local host authority or Cloudflare Worker/D1 deployment authority can run:
```bash
fed deploy --admin-reset
```
Non-interactive environments must also pass `--yes`; this only confirms the reset and does not skip credential prompts. The CLI applies recovery directly through a local SQLite transaction or Wrangler-authorized remote D1 access; Workers do not receive recovery environment variables, and plaintext passwords never enter the database. A successful recovery revokes every previous administrator session. Federation exposes no anonymous password-reset API.
`fed deploy` continuously reports the current stage and elapsed time for builds, typechecks, Cloudflare account checks, D1 / Queue / R2 preparation, Worker deployment, health checks, and administrator verification. CI and redirected output receive stable line-based progress logs instead of terminal animation.
`fed bureau token` opens the interactive token manager. Choose Issue, or run
`fed bureau token issue `, to enter a purpose and use an Admin Session to ask Federation
to issue the credential. Federation generates the plaintext and stores only its purpose, hash, and
lifecycle state. The plaintext is returned once and configured as `DOWNCITY_BUREAU_TOKEN` on the
Bureau server. Federation and Bureau can run on different servers. A Bureau Token proves machine
identity and cannot issue User Tokens.
## Where to learn more
- [City SDK](/en/city-sdk-docs) — building on City and Federation
- [Payments](/en/payments) — usage, balance, and billing APIs
### Organizations
Source: https://downcity.ai/en/docs/federation/organizations/
`OrganizationsService` owns Organizations, Memberships, Join Requests, and governance roles. Federation is the authority for these relationships.
An Organization stores no product backend address and owns no product resources. Bureau reads its Bureau URL from trusted product configuration and sends the current `user_token` to that Bureau. Bureau verifies the user, checks Membership, and applies product resource permissions.
- Client and backend API: [Organizations Federation API](./organizations-api)
- Product backend integration: [Integrate Organizations into Bureau](./organizations-bureau)
## Install
```ts
import { Federation } from "@downcity/federation";
import { OrganizationsService } from "@downcity/services";
const federation = new Federation({ database });
federation.use(new OrganizationsService({
max_organizations_per_user: 3,
}));
```
The quota counts every active Organization the user owns across Federation. Archiving releases the slot, and ownership transfer moves it to the new Owner.
## Organization scope
Create a Federation-wide Organization:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: process.env.DOWNCITY_FEDERATION_URL!,
user_token,
});
await embassy.user.service("organizations").action("create").invoke({
name: "Genesis Research",
scope_type: "federation",
});
```
Create an Organization for the current Bureau:
```ts
await embassy.user.service("organizations").action("create").invoke({
name: "Genesis Research",
scope_type: "bureau",
});
```
Members can access a `federation` Organization with a token for any Bureau in the same Federation. A `bureau` Organization requires `user_token.bureau_id` to equal `scope_bureau_id`. Federation derives the Bureau scope from the verified token; clients cannot submit or override it.
Federation-wide does not mean public. The service exposes no public search. Products distribute `organization_id` through invitation links, QR codes, or their own flows.
## Membership
A Membership represents one period in which a User belongs to an Organization. Leaving or removal permanently closes that record. Rejoining creates a new `membership_id`.
```ts
await embassy.user.service("organizations").action("join-requests/create").invoke({
organization_id: "org_01J...",
});
```
Owner, Admin, and Member are Organization governance roles. They do not grant editor, viewer, or repository permissions in a product.
## Call a product Bureau
Bureau sends the same `user_token` to the HTTPS Bureau configured by the product:
```ts
await embassy.user.post("/v1/projects", {
organization_id: "org_01J...",
});
```
Bureau verifies the token with Federation JWKS and calls `membership/get` for the current relationship. Organizations Service issues no `organization_token` and makes no callback to user-controlled URLs.
## Databases and archive
The service supports better-sqlite3, PostgreSQL, and Cloudflare D1 through `context.transaction()`. It contains no database-specific branch.
Archive is terminal. It stops governance writes and releases the Owner quota. Bureau decides what happens to product resources.
### Organizations Federation API
Source: https://downcity.ai/en/docs/federation/organizations-api/
This reference covers Organizations Actions for clients and Bureau. See [Integrate Organizations into Bureau](./organizations-bureau) for product authorization.
## Install
```ts
import { Federation } from "@downcity/federation";
import { OrganizationsService } from "@downcity/services";
const federation = new Federation({ database });
federation.use(new OrganizationsService({
max_organizations_per_user: 3,
}));
```
| Option | Required | Meaning |
| --- | --- | --- |
| `max_organizations_per_user` | yes | Maximum active Organizations a user may own across Federation |
The service supports better-sqlite3, PostgreSQL, and Cloudflare D1 through `context.transaction()`.
## Endpoint and authentication
```text
{federation_origin}/v1/organizations/{action}
```
Every Action requires a Federation `user_token`:
```http
Authorization: Bearer ub_
```
Federation reads `user_id` and `bureau_id` from the verified Token. GET Actions use query parameters; POST Actions use JSON.
## Types
```ts
type Organization = {
organization_id: string;
name: string;
scope_type: "federation" | "bureau";
scope_bureau_id: string;
state: "active" | "archived";
created_by: string;
created_at: string;
updated_at: string;
archived_at: string;
};
type Membership = {
membership_id: string;
organization_id: string;
user_id: string;
role: "owner" | "admin" | "member";
state: "active" | "removed";
created_at: string;
updated_at: string;
removed_at: string;
removed_by: string;
};
type JoinRequest = {
request_id: string;
organization_id: string;
user_id: string;
state: "pending" | "approved" | "rejected" | "canceled";
requested_at: string;
decided_at: string;
decided_by: string;
};
```
For `scope_type = federation`, `scope_bureau_id` is empty. For `scope_type = bureau`, Federation derives `scope_bureau_id` from the creator's verified `user_token.bureau_id`.
## Permissions
| Operation | Owner | Admin | Member |
| --- | --- | --- | --- |
| Read Organization and members | yes | yes | yes |
| Rename | yes | yes | no |
| Decide Join Requests | yes | yes | no |
| Remove Member | yes | yes | no |
| Remove Admin | yes | no | no |
| Assign or revoke Admin | yes | no | no |
| Transfer ownership | yes | no | no |
| Archive | yes | no | no |
| Leave | transfer first | yes | yes |
## Actions
| Method | Action | Authorization | Purpose |
| --- | --- | --- | --- |
| `GET` | `my` | User | List visible Organizations for the caller |
| `GET` | `get` | Member | Read Organization and current Membership |
| `POST` | `create` | User | Create an Organization as Owner |
| `POST` | `update` | Owner/Admin | Rename |
| `POST` | `archive` | Owner | Archive terminally |
| `GET` | `membership/get` | Member | Read current Membership |
| `GET` | `members/list` | Member | List active Memberships |
| `POST` | `members/role` | Owner | Set Admin or Member |
| `POST` | `members/remove` | Owner/Admin | Remove a manageable member |
| `POST` | `members/leave` | Admin/Member | Leave |
| `POST` | `owner/transfer` | Owner | Transfer the unique Owner |
| `POST` | `join-requests/create` | User | Apply to join |
| `POST` | `join-requests/cancel` | Applicant | Cancel a pending request |
| `GET` | `join-requests/list` | Owner/Admin | List pending requests |
| `POST` | `join-requests/decide` | Owner/Admin | Approve or reject |
## Organization Actions
### `GET my`
```http
GET /v1/organizations/my
GET /v1/organizations/my?include_archived=true
```
The default response contains active Organizations only. Each item includes `role`, `membership_id`, and `membership_state`. Federation Organizations are visible across Token Cities; Bureau Organizations require a matching Token Bureau.
### `GET get`
```http
GET /v1/organizations/get?organization_id=org_01J...
```
Requires an active Membership and returns `{ organization, membership }`. Members may read an archived Organization but cannot mutate it.
### `POST create`
Federation-wide:
```json
{ "name": "Genesis Research", "scope_type": "federation" }
```
Current Bureau:
```json
{ "name": "Genesis Research", "scope_type": "bureau" }
```
Returns `{ organization, membership }`, with the caller as the unique Owner. The trimmed name must contain 1–120 characters.
### `POST update`
```json
{ "organization_id": "org_01J...", "name": "New Name" }
```
Owner/Admin only. Scope is immutable after creation.
### `POST archive`
```json
{ "organization_id": "org_01J..." }
```
Owner only and irreversible. Archive stops governance writes and releases the Owner quota. Membership and Join Request history remains.
## Membership Actions
### `GET membership/get`
```http
GET /v1/organizations/membership/get?organization_id=org_01J...
```
Returns `{ organization, membership }`. Bureau may forward the same user Token to this endpoint. Removed users receive `403 NOT_AN_ORGANIZATION_MEMBER`.
### `GET members/list`
```http
GET /v1/organizations/members/list?organization_id=org_01J...
```
Returns active Memberships in `{ "items": Membership[] }`.
### `POST members/role`
```json
{
"organization_id": "org_01J...",
"membership_id": "mem_01J...",
"role": "admin"
}
```
Owner only. `role` accepts `admin` or `member`. Use `owner/transfer` for ownership.
### `POST members/remove`
```json
{ "organization_id": "org_01J...", "membership_id": "mem_01J..." }
```
Owners may remove Admins or Members. Admins may remove Members. The unique Owner cannot be removed.
### `POST members/leave`
```json
{ "organization_id": "org_01J..." }
```
Admins and Members may leave. The Owner transfers ownership first.
### `POST owner/transfer`
```json
{ "organization_id": "org_01J...", "membership_id": "mem_new_owner" }
```
The target must have an active Membership and an available Owner quota. The previous Owner becomes Admin.
## Join Request Actions
### `POST join-requests/create`
```json
{ "organization_id": "org_01J..." }
```
Users apply with a known ID. A duplicate returns the same pending request. Existing members receive `{ state: "joined", organization, membership }`.
### `POST join-requests/cancel`
```json
{ "request_id": "join_01J..." }
```
The applicant may cancel their pending request.
### `GET join-requests/list`
```http
GET /v1/organizations/join-requests/list?organization_id=org_01J...
```
Owner/Admin only. Returns pending requests.
### `POST join-requests/decide`
```json
{ "request_id": "join_01J...", "decision": "approved" }
```
`decision` accepts `approved` or `rejected`. Approval creates a new Member Membership in the same transaction.
## Errors
| HTTP | Code | Meaning |
| --- | --- | --- |
| `400` | `ORGANIZATION_INPUT_INVALID` | Invalid name, ID, role, or decision |
| `400` | `ORGANIZATION_SCOPE_INVALID` | Invalid `scope_type` |
| `401` | `AUTH_REQUIRED` | Missing valid user identity |
| `403` | `ORGANIZATION_BUREAU_MISMATCH` | Token Bureau cannot access the Bureau Organization |
| `403` | `NOT_AN_ORGANIZATION_MEMBER` | No active Membership |
| `403` | `ORGANIZATION_ROLE_DENIED` | Governance role denies the operation |
| `404` | `ORGANIZATION_NOT_FOUND` | Organization does not exist |
| `404` | `ORGANIZATION_MEMBERSHIP_NOT_FOUND` | Membership is absent or inactive |
| `404` | `JOIN_REQUEST_NOT_FOUND` | Join Request is absent or unavailable |
| `409` | `ORGANIZATION_LIMIT_REACHED` | Owner quota is full |
| `409` | `OWNER_TRANSFER_REQUIRED` | Transfer ownership first |
| `410` | `ORGANIZATION_ARCHIVED` | Organization is archived |
Branch on `error.code`, not the message text.
### Integrate Organizations into Bureau
Source: https://downcity.ai/en/docs/federation/organizations-bureau/
Bureau is the trusted product backend. Bureau sends the Federation `user_token` to the HTTPS Bureau configured by the product. Bureau verifies the user and Bureau before checking Organization Membership.
## Authorization order
```text
Bureau
-> user_token + product request
-> Bureau verifies Federation JWT and bureau_id
-> Bureau reads organization_id from the resource record
-> Bureau checks Federation Membership
-> Bureau enforces product roles and resource permissions
```
A valid Membership means that the user belongs to the Organization. It does not grant access to every product resource owned by that Organization.
## Verify `user_token`
Bureau uses its configured Federation issuer and JWKS to validate:
- JWT algorithm and signature;
- `iss` and `aud`;
- `exp` and `nbf`;
- `user_id` and `bureau_id`;
- whether `bureau_id` is allowed by the current product.
Do not log the Authorization header or forward the token to services outside product configuration.
## Check Membership
Bureau can forward the same user Token to Federation:
```http
GET /v1/organizations/membership/get?organization_id=org_01J...
Authorization: Bearer ub_...
```
A successful response contains the Organization and active Membership. Federation returns `403 NOT_AN_ORGANIZATION_MEMBER` after the user leaves or is removed.
## Verify resource ownership
Product resource tables store `organization_id`:
```sql
CREATE TABLE projects (
project_id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL
);
```
For `/v1/projects/:project_id`, Bureau reads the Project first and checks Membership against `project.organization_id`. Never use a client-supplied Organization ID as the resource authority.
## Product permissions
Bureau applies its own permission model after Membership succeeds:
```text
Federation Membership
-> user belongs to Organization
Product Role / ACL
-> user may read, edit, or manage a resource
```
Organization Owner, Admin, and Member govern the Federation relationship only. Bureau stores product roles such as editor, viewer, or billing_admin.
## Cache and revocation
Check Federation online for writes, administration, and sensitive reads that require immediate revocation. Low-risk reads may use a short-lived cache; the maximum revocation delay equals the cache TTL.
At minimum, include Federation issuer, `user_id`, and `organization_id` in the cache key. Bureau owns the TTL decision. Organizations Service maintains no cross-server revocation Outbox.
## Checklist
- [ ] Read the Bureau URL from trusted product configuration
- [ ] Receive `user_token` only over HTTPS
- [ ] Verify issuer, audience, signature, time, and `bureau_id`
- [ ] Do not log or forward the Authorization header
- [ ] Read `organization_id` from the resource record
- [ ] Check Membership online for sensitive operations
- [ ] Apply product permissions after Membership
### Model Provider
Source: https://downcity.ai/en/docs/federation/model-provider/
The Federation AIService abstracts over multiple AI model providers. A model provider is any backend that can serve model requests — OpenAI, Anthropic, Google, or a local inference server.
## Registration
Providers register with City by exposing:
- Available model IDs
- Capability flags (streaming, tool use, vision, etc.)
- Authentication requirements
## Unified API
Regardless of the backend, the Federation AIService exposes a unified API to Agents. This means:
- Agents do not need provider-specific code
- Switching providers is a configuration change, not a code change
- New providers can be added without modifying Agents
## Capability discovery
When an Agent binds to a model ID, City reports:
- Whether the model supports streaming
- Whether the model supports tool use
- Maximum context length
- Pricing per token
Continue with:
- [Service](/en/docs/federation/service)
- [Action](/en/docs/federation/action)
### Local Proxy
Source: https://downcity.ai/en/docs/federation/local-proxy/
Use the Downcity local proxy skill when you want a local Federation service that exposes Codex or Claude Code model access without registering anything into an existing Fed server.
The generated project runs:
- CLIProxyAPI on `127.0.0.1:8317`
- Downcity local Federation on `127.0.0.1:3000`
- Model endpoints under `/v1/ai/models` and `/v1/ai/chat/completions`
## Install the skill
Run the following command from a joint workspace checkout containing the private repository.
Install the skill with the Vercel Labs `skills` CLI:
```bash
npx skills add ./private/apps/homepage/resources/downcity-local-proxy-skill \
--skill downcity-local-proxy \
--agent codex \
--global \
--yes
```
## Prompt Codex
After installing, ask Codex:
```text
Use downcity-local-proxy to create ./downcity/local-proxy with Codex and Claude Code enabled, then install dependencies and start the local Federation service.
```
## Start manually
The skill creates a standalone project. From the generated directory:
```bash
cd ./downcity/local-proxy
pnpm install
pnpm start --codex
pnpm start --claude-code
pnpm start --codex --claude-code
```
Use `--login` when CLIProxyAPI needs OAuth setup or refresh:
```bash
pnpm start --codex --login
pnpm start --claude-code --login
```
The local proxy writes runtime files under `.downcity/` inside the generated project. It does not modify global Downcity CLI state or register itself with an existing Fed server.
Continue with:
- [Model Provider](/en/docs/federation/model-provider)
- [Service](/en/docs/federation/service)
### Service
Source: https://downcity.ai/en/docs/federation/service/
A **Service** is a long-running backend capability that Agents can call. Unlike tools (which run inside the Agent process), Services run as independent backends and are accessed via the City network.
## Examples
- Knowledge base (semantic search over documents)
- Search engine (web search, internal search)
- Database (structured data storage and query)
- File store (document upload and retrieval)
## Registration
Services register with City by:
1. Declaring their API schema
2. Exposing health and capability endpoints
3. Receiving a service ID
## Discovery
Agents discover Services through City. When an Agent needs a capability, it asks City for available Services, then routes requests to the appropriate one.
Continue with:
- [Model Provider](/en/docs/federation/model-provider)
- [Action](/en/docs/federation/action)
### Action
Source: https://downcity.ai/en/docs/federation/action/
An **Action** is a discrete operation that an Agent can invoke. It is the unit of work in the Service/Action layer. Actions are exposed by Services and can be composed into workflows.
## Examples
- `send_email` — send an email to a recipient
- `query_database` — run a SQL query
- `call_api` — invoke an external HTTP API
- `upload_file` — store a file in the file store
## Schema
Every Action declares:
- Input schema (parameters it accepts)
- Output schema (what it returns)
- Error conditions
- Required permissions
## Invocation
Agents invoke Actions through the City layer. The City routes the request to the Service that owns the Action, then returns the result to the Agent.
## Composition
Actions can be composed into higher-level workflows. For example, a "onboard user" workflow might invoke `create_account`, `send_welcome_email`, and `add_to_crm` in sequence.
Continue with:
- [Model Provider](/en/docs/federation/model-provider)
- [Service](/en/docs/federation/service)
### Routing
Source: https://downcity.ai/en/docs/federation/routing/
The Federation AIService handles request routing and failover across model providers and Services. This ensures Agents stay available even when individual backends are down.
## Model routing
When an Agent sends a model request:
1. The Federation AIService looks up the model ID
2. It selects the provider with the best health and lowest latency
3. It forwards the request
4. If the provider fails, it retries with the next available provider
## Service routing
When an Agent invokes an Action:
1. City resolves the Action to the Service that owns it
2. It checks the Service's health and load
3. It routes the request to the best instance
4. If the Service is unavailable, it returns an error or queues the request
## Failover
- Provider health is monitored continuously
- Failed requests are retried with exponential backoff
- Circuit breakers prevent cascading failures
- Load balancing distributes traffic across healthy instances
Continue with:
- [Model Provider](/en/docs/federation/model-provider)
- [Service](/en/docs/federation/service)
### Architecture
Source: https://downcity.ai/en/docs/concepts/architecture/
Current Downcity is best understood through five layers:
1. `console`
2. `agent`
3. `execution context`
4. `session`
5. `power runtime`
## Layers
### `console`
- global control plane
- process registry
- city-level configuration and inspection
### `agent`
- owns one project runtime
- holds model access, session store, power registry, and runtime host state
### `execution context`
- shared capability surface exposed during execution
- the place powers read stable runtime abilities
### `session`
- owns one execution thread and its prompt/run state
- the unit that actually runs model + tool flow
### `power runtime`
- all capability modules are modeled as powers
- some are local and action-first
- some are managed and lifecycle-owned
## Managed vs local powers
### Local powers
- usually attached directly to one SDK `Agent`
- examples: `skill`, `web`, `sound`
### Managed powers
- runtime-owned modules that start and stop with one target agent
- examples: `chat`, `task`, `memory`, `shell`, `schedule`
## Main takeaway
Do not use the old split-layer language for the current system.
The current model is:
- session executes
- powers expose capability
- managed powers own long-lived runtime modules
### Project Logic Overview
Source: https://downcity.ai/en/docs/concepts/project-logic-overview/
Downcity is easiest to understand from the product problem first: AI builders should not rebuild the same agent backend for every product, workflow, or customer demo.
The core relationship is:
- product surfaces own the user experience
- agents execute work inside explicit runtime boundaries
- Downcity runtime services own repeated infrastructure such as models, env, auth, service routing, usage, balance, and payment
- Console, Chrome Extension, SDKs, and UI components expose that runtime to builders and teams
## Runtime nouns
- `downcity`: the overall runtime and CLI surface
- `console`: global control plane
- `agent`: one project runtime
- `session`: one execution thread
- `power`: one capability boundary
- `city`: the current package/runtime name for City infrastructure
## The shortest model
- products create the experience
- agents run the work
- sessions execute turns
- powers expose capabilities
- City keeps repeated backend concerns out of every product
## Power families
### Local powers
- attached directly to one SDK `Agent`
- usually action-first or hook-oriented
### Managed powers
- owned by one target agent runtime
- may hold lifecycle-owned runtime state
## Why this matters
When reading current code or docs, start with the product boundary:
- if you need product operation, look at `downcity`, `console`, and product docs
- if you need execution ownership, look at `agent` and `session`
- if you need capability ownership, look at `power`
- if you need shared backend ownership, look at the City SDK and services docs
### Security and Permissions Overview
Source: https://downcity.ai/en/docs/security/overview/
Downcity security has three layers:
1. Agent isolation: one running agent is bound to one `projectRoot`.
2. Chat Access: per-Agent grants decide which external Chat users can send messages into the Agent.
3. Local configuration: model, Workspace, Agent, and Power configuration is stored on the local machine and primarily isolated by file permissions.
## What to watch first
1. Avoid stale `DC_CITY_HOST/DC_CITY_PORT` in your shell, or requests may hit the wrong agent.
2. New users cannot enter an Agent by default, so review pending Access Requests after enabling a channel.
3. Local configuration is plaintext. Do not copy `~/.downcity`, Agent configuration, or Power configuration to untrusted locations.
## Recommended reading
1. [API Authentication](/en/docs/security/api-authentication)
2. [Permissions](/en/docs/security/permissions)
3. [Data Storage and Permissions](/en/docs/security/data-security)
4. [Invocation Routing and Isolation](/en/docs/concepts/invocation-routing-and-isolation)
### API Authentication
Source: https://downcity.ai/en/docs/security/api-authentication/
Downcity now uses a single external auth model:
- no username/password sign-in
- no browser session layer
- all external auth goes through Bearer tokens
- tokens are managed by the local CLI on the same machine
The currently exposed endpoints are:
- `GET /api/auth/status`
- `GET /api/auth/me`
- `GET /api/auth/token/list`
- `POST /api/auth/token/create`
- `POST /api/auth/token/delete`
## Token ownership
Tokens are created and stored on the same local machine that owns the runtime. A token name is only for human identification and management. It does not represent a different token type or channel scope.
## Use the token
Send the token through the `Authorization` header:
If the token is valid, the endpoint returns the current subject, roles, and permissions.
## Manage tokens
Token creation, listing, and deletion are local-machine operations. HTTP token-management endpoints require an existing Bearer token, so external callers cannot silently mint authority without first holding authority.
## `DC_AUTH_TOKEN`
Main rule first:
- only external HTTP calls need a Bearer token
- local managed commands now call the local agent daemon over HTTP by default
For direct API usage, the important rule is:
- external calls always authenticate through `Authorization: Bearer `
Inside the CLI/runtime, the only auth env var most users need is:
| Variable | Intended For | Purpose |
| --- | --- | --- |
| `DC_AUTH_TOKEN` | users, scripts, CI | explicitly choose which token a CLI/API invocation should use |
HTTP calls that need auth use this resolution order:
1. `--token`
2. `DC_AUTH_TOKEN`
So:
- `DC_AUTH_TOKEN` is the explicit override
- protected HTTP calls normalize `DC_AUTH_TOKEN` into the Bearer header
If you see `Missing bearer token`:
- do not start by suspecting `sessionId`
- first confirm that the target HTTP endpoint is actually protected
- then check whether the current process has any valid token source
- if this happens inside an agent/Bash/ACP subprocess, first check whether the command should receive an explicit token
Concrete token workflows live in [Downcity CLI product docs](/en/docs/reference/cli).
## Current scope
The current model is intentionally token-only:
- username/password login removed
- `bootstrap-admin` removed
- `login` removed
- `password/update` removed
- Console UI, Chrome extension, and external scripts all use explicitly minted Bearer tokens
Future integrations will stay on this Bearer token model instead of reintroducing a separate login session layer.
### Chat Access
Source: https://downcity.ai/en/docs/security/permissions/
Chat Access is an admission capability of the Messenger Power. It answers one question: may a direct or group message from an external Chat user enter a specific Agent?
It is not an Agent Core permission system or a general Role/Permission model. After admission, the Messenger Power passes the message to the Agent without propagating roles or permissions into Sessions or other Powers.
## Core rules
- Default deny: without an explicit `allow` grant, the message does not enter the Agent.
- Per-Agent isolation: approval for Agent A does not grant access to Agent B.
- Per-account isolation: identity uses `channel + issuer + subject_id`, so the same user contacting two bots has two principals.
- Explicit scopes: persisted grants are only `direct` and `group`; `all` in management commands expands to two grants.
- `deny` differs from `revoke`: denial prevents new requests, while revocation lets the user request access again.
## Approval flow
When an unapproved user sends a direct message, the Messenger Power creates or reuses a pending request and returns a command like:
```bash
city power action chat access-approve assistant --input '{"request_id":"req_xxx"}' --token
```
Administrators can inspect requests first:
```bash
city power action chat access-snapshot assistant --input '{}' --token
```
Unapproved group messages are silent by default, but their pending requests appear in the same management list.
## Storage
Each Agent stores its own data at:
```text
~/.downcity/agents//powers/messenger/messenger/access.db
```
The Chat Access database contains principals, requests, allow/deny grants, and audit events. It does not contain bot tokens, app secrets, or message bodies. Messenger channel credentials belong to the unique plaintext configuration in `~/.downcity/powers/messenger/config.toml`; the Messenger Config UI never returns their plaintext values when reading configuration.
## Difference from City Auth
An Agent Token protects the HTTP control plane. Each token belongs to one Agent and has full access to that Agent API; it contains no User, Role, or Permission. Chat Access decides whether an external Telegram, Feishu, or QQ user may send messages to a specific Agent. Their data, identities, and decision paths are separate.
## Related docs
- [CLI Commands](/en/docs/cli/commands)
- [Messenger Power](/en/docs/power/messenger)
- [`.downcity` Directory](/en/docs/reference/dot-downcity)
### Data Storage and Permissions
Source: https://downcity.ai/en/docs/security/data-security/
Local CLI and Desktop state use plaintext SQLite/JSON files:
1. Workspace indexes, platform settings, and local tokens: `~/.downcity/downcity.db`
2. Agent definitions and identity instructions: `~/.downcity/agents//agent.json` and `SOUL.md`
3. Power definitions and unique configuration: `~/.downcity/powers//power.json` and `config.toml`
## File Permissions and Boundaries
Database files, Agent configuration directories, and Power configuration files are restricted to the current user by default (`0600` for files and `0700` for directories). This is not encrypted storage: a process that can read the local user's files can also read these configurations.
`city env` is an explicit file-based configuration feature. Global Env is stored in plaintext at
`~/.downcity/.env`, and Workspace Env is stored in plaintext in the project `.env`. The Global file
is restricted to the current user, and project `.env` is added to `.gitignore` by default. Use the
launch environment or a secret manager when stronger secret protection is required.
## Best practices
1. Never commit `~/.downcity` or local configuration files.
2. Never commit Workspace `.env`, and keep `~/.downcity/.env` accessible only to your user account.
3. Use process environment variables or an operating-system secret manager when stronger protection is required.
## Related docs
- [Model binding](/en/docs/configuration/model)
- [Model Configuration](/en/docs/configuration/model)
### CLI
Source: https://downcity.ai/en/docs/cli/overview/
The Downcity package installs two independent tools:
- `city` (alias `downcity`) is the local City container for Agents, powers, environment variables, and Federation connections.
- `fed` (alias `downfed`) is the Federation Server Manager. Its TUI manages registered servers, `fed web` starts the local Web control plane, and project commands read `federation.json` from the current project.
## City data model
Agents and Workspaces are independent global entities. An Agent is identified by `agent_id`, keeps its structured definition in `~/.downcity/agents//agent.json`, and keeps its stable identity instructions in `SOUL.md`. A Workspace is identified by `workspace_id` and points to a project directory. An Agent can enter multiple Workspaces; neither entity stores a persistent binding to the other. CLI and Desktop own separate City instances but share the same user-level files and Workspace index.
Federation Admin configuration and Embassy User Sessions also live in `~/.downcity/downcity.db`. On upgrade, administration configuration in the legacy `~/.downcity/federation.db` is migrated once. The old file remains available for manual recovery but is no longer read at runtime.
Each Power uses its stable ID as `~/.downcity/powers//`, with one plaintext configuration in `config.toml`. A third-party installation contains one `power.json`, the `package.json` that establishes its ESM boundary, and optional self-contained `main` and `renderer` entries declared by the manifest—not source or build configuration. City provides every registered Power and the same configuration to every Agent; `agent.json` stores no Power reference. `downcity.db` stores Workspace indexes, tokens, and platform state, not Agent or Power configuration.
## City commands
| Command | Purpose |
|---|---|
| `city agent` | Manage global Agents |
| `city power` | Manage Power artifacts, unique configuration, and actions |
| `city env` | Manage environment variables injected by City |
| `city federation` | Manage Federation connections |
Automation can override the current Embassy user identity with `DOWNCITY_FEDERATION_URL` and `DOWNCITY_USER_TOKEN`. The legacy `DOWNCITY_CITY_URL`, `DOWNCITY_CITY_USER_TOKEN`, `CITY_URL`, and `CITY_USER_TOKEN` variables are no longer parsed.
`city init`, `city update`, and `city config` are no longer provided. Upgrade globally with `npm i -g downcity@latest`.
Every command that operates on one Agent targets an `agent_id`. If omitted in a TTY, City opens the global Agent selector. Non-interactive calls fail with `Agent ID is required`; the current directory is never used for inference.
## Platform support
The CLI architecture is cross-platform. macOS and Linux are stable targets; Windows adapters are currently preview quality.
Continue with [CLI commands](/en/docs/cli/commands).
### CLI Commands
Source: https://downcity.ai/en/docs/cli/commands/
## City lifecycle
```bash
city on # Start the single CLI City daemon
city on --port 5314 --rpc-port 15314
city status
city restart
city off
city reset # Confirm and reset the local configuration database
city reset --yes # Skip confirmation
```
The CLI City uses one HTTP port and one native TCP RPC port. Both protocols route all Agents by `agent_id`.
`city reset` first stops the CLI City daemon, then removes `downcity.db`, `downcity.db-wal`, and `downcity.db-shm`. It preserves `.env`, Agent/Power configuration, and Session files inside Workspaces.
## Agent
```bash
city # Open the Agent list directly
city agent # Open the same Agent list
city agent create [path] # No path: current directory or native folder picker
city agent list
city agent model [agent_id]
city agent chat [agent_id]
```
The bare `city` command opens the Agent list directly. It does not show Agent runtime status. `city agent list` prints every Agent as `name · agent_id`. Federation, global Powers, language, and command help appear in a Settings group at the end of the same list.
Agents assembled by City enable the `ask_question` tool by default.
Selecting an Agent in the `city agent` panel opens its most recent conversation and enters that Session's Workspace. When the Agent has no history yet, Chat opens in the current Workspace and creates a new Session. The model can present text, single-select, or multi-select
questions when key information is missing. The CLI submits the complete answer set
to the current Session and continues the same Turn. Press Esc to stop that Turn.
Agents and Workspaces are registered independently. An Agent enters a selected Workspace for each execution and stores no persistent Workspace binding. Agents have no independent started state. `city agent chat --session-id ` must reference an existing Session and enters that Session's Workspace; `--workspace ` keeps Chat in that Workspace by limiting session lookup to it, requiring it to match the Workspace of a selected `--session-id` Session, and creating new Sessions there. In Chat, use `/new` to create a Session, `/session` to select an existing Session or create one, and `/config` to open Agent configuration. Configuration returns to the same conversation. Chat uses RPC while City is running; scriptable one-shot calls can use the local assembly path while City is off.
Use the slash command to open the system file picker and attach local files to the next message:
```text
/attach
```
`/file` is an alias. On Unix, `Ctrl-V` (or `Alt-V` on Windows) also tries to paste clipboard images or files first, and falls back to normal text paste when no supported attachment is available.
Approval requests are handled by the dedicated interaction panel; `/approve` and `/deny` are no longer slash commands.
## City Tokens
City owns connection credentials. Grant access to selected Agent IDs, or explicitly allow all existing and future Agents. `city` is the executable; `downcity` is an alias, without an additional `city` subcommand.
```bash
city token
city token create
city token list
city token create --name chrome --agents researcher,programmer --never-expires
city token create --name automation --all-agents --expires-at 2027-01-01T00:00:00Z
city token update --agents researcher
city token delete
```
The interactive menu supports creation, summary viewing, editing and revocation. Creation requires a name, scope and expiry policy. Missing options are prompted in a terminal and rejected in noninteractive execution. Interactive creation includes Agent multiselect, 30/90 days, no expiry or a custom date, followed by confirmation. Omit the ID on update/delete to select a record. Complete options run directly; use `--json` for scripts.
Plaintext is shown once at creation and never appears in summary queries. Enter it directly in Chrome settings. Lost credentials must be replaced. Revocation or scope reduction rejects new requests and closes affected HTTP streams within the approximately one-second authorization check interval. Cancellation cannot undo completed external effects.
The old `city agent token` command and Agent Tokens are no longer accepted. Issue a new credential with `city token create`; old records are not automatically migrated or granted broader access. Token management remains local and is not granted to remote connection credentials.
## Powers
`city power list` shows each Power title, stable ID, purpose, source, and whether it provides Config.
```bash
city power list
city power install
city power update
city power uninstall
city power inspect
city power config
city power config --set '{"key":"value"}'
city power action [agent_id] --input '{"key":"value"}' --token
```
A third-party source directory defines exactly one Power. Its `power.json` declares `schema_version: 1`, a globally unique `id`, a version, a required relative `readme` path to a `.md` file, and optional `main` and `renderer` entries. `main` is a self-contained unified City Power ESM. The `renderer` object declares a single-file ESM entry and static `sidebar`, `mainview`, and `config` capabilities; Sidebar and Mainview must be paired. At least one of the two runtime entries is required. `package.json` must declare `"type": "module"`. The installed directory keeps only declared artifacts, documentation, an optional icon, and local `config.toml`. Installation never runs dependencies, build scripts, or any third-party entry.
A Power that declares Config stores one configuration in `powers//config.toml`. The Power
owns its structure, validation, Secret projection, and Config UI. CLI `config --set` only replaces a
complete JSON object explicitly; it does not generate a generic form or print credential values. Without
`--set`, it reports only whether Config exists and its field names.
City automatically provides every registered Power to every Agent; `agent.json` stores neither Power
references nor configuration. A Power models its own account collection and selection rules when it
needs multiple accounts. After Config is saved, the Power refreshes affected long-lived resources.
The CLI, Agent Power tools, HTTP, and scheduled jobs all invoke Power Actions. There is no `power command`, and Chat is not a top-level CLI special case.
## Env and Federation
```bash
city env # open the Env TUI
city env list --global
city env list --agent
city env set --global
city env set --agent
city env delete --global
city env delete --agent
city federation
```
Global Env is stored in `~/.downcity/.env`; Workspace Env is stored in `.env` at the project root.
You can also use “Configure Env” in `city agent` to select an Agent's Workspace. After a change, the CLI City updates affected
runtime Agents and Workspace broadcasts the new snapshot to existing Sessions.
Agents not held by that City receive the configuration the next time a City assembles them.
Agents running in the same Workspace share one project `.env`. Runtime precedence is Global `.env`,
then Workspace `.env`, then explicit process environment variables. `list` never prints plaintext
values; `copy` requires an explicit local CLI invocation.
Federation user requests go through Embassy. Interactive sessions are stored in
`~/.downcity/downcity.db`; automation can explicitly override the current session with
`DOWNCITY_FEDERATION_URL` and `DOWNCITY_USER_TOKEN`. These identity variables are never injected
from Global `.env` into Workspace processes.
## Fed
```bash
fed # Federation Server manager TUI
fed web # start the local Web admin UI for the active Federation
fed web --no-open --port 43128 # start without opening the browser
fed server add
fed server manage
fed bureau token # open the Bureau Token manager TUI
fed bureau token issue # enter a purpose and ask Federation to issue a token
fed bureau token list
fed bureau token revoke
fed query GET /health
fed create [dir]
fed deploy [source]
```
`fed web` starts the local control plane at `http://127.0.0.1:43128` by default. It uses the
active Federation, or one selected with `--federation `. The local CLI process keeps
the administrator session token; it is never sent to the browser. To keep the administrator control plane off the
network, the command only listens on `127.0.0.1`, `localhost`, or `::1`. The UI includes the
Dashboard, users, Bureaus, models, Env, Credits, Usage, payments, the Service catalog, and a
debugger. Analytics are organized into overview, activity, consumption, retention, quality, and
per-user detail views.
The Usage Dashboard uses the browser's IANA timezone. DAU counts unique users with at least one
AI Usage record today; WAU and MAU use rolling 7-day and 30-day windows. The user-usage view lists
all registered users, including users with zero usage. It defaults to descending Total Tokens—the
same metric as the overview's token-consumption ranking—and also supports sorting by calls, Credits,
P95 duration, and last activity. Each row includes input, output, and total tokens, success
rate, top model, duration, and applied Credits consumption. Charts cover DAU/WAU/MAU, local-hour
activity, stacked token composition, model/action distribution, outcomes, latency, and
D1/D3/D7/D14/D30 cohort retention.
### Project Init
Source: https://downcity.ai/en/docs/cli/init/
`city agent create` registers the Workspace independently and creates the Agent's user-level `agent.json` and `SOUL.md`. It does not generate an Agent declaration inside the Workspace or persist an Agent-Workspace binding.
```bash
city agent create
```
Without a path, the interactive flow lets you use the current directory or open the operating system's native folder picker. Pass a relative or absolute path to skip that choice:
```bash
city agent create .
city agent create ./my-project
city agent create /absolute/path/to/my-project
```
Generated structure:
```text
my-project/
├── .env
├── .agents/
│ └── skills/
└── .downcity/
```
The Agent definition is written to `~/.downcity/agents//agent.json` with its `SOUL.md`; Workspace registration remains in `downcity.db`. Project `.env` overrides Global Env from `~/.downcity/.env`; explicit process environment variables have the highest priority.
```bash
# Allow replacing an existing Agent file definition
city agent create ./my-project --force
```
After initialization, add project Skills under `.agents/skills`, inspect the generated `agent_id` with `city agent list`, then run `city agent chat ` directly or use `city on` for the long-running City host.
Continue with:
- [Project Structure](/en/docs/cli/project)
### Project Structure
Source: https://downcity.ai/en/docs/cli/project/
Agent definitions, Power configuration, and runtime data use user-level files. A project directory is only a Workspace containing project env, Skills, and business source files.
## Directory layout
```text
my-project/
├── .env # project environment variables
├── .agents/
│ └── skills/ # user-managed, versioned Skills
└── src/ # application code (optional)
```
## Global config
The user-level configuration layout is:
```text
~/.downcity/
├── agents//
│ ├── agent.json
│ ├── SOUL.md
│ └── sessions/
│ ├── /
│ ├── archived-sessions/
│ └── logs/
├── powers//config.toml
├── runtimes/city/
└── downcity.db
```
`downcity.db` stores Workspace IDs and paths, tokens, and platform state. It does not store Agent definitions, Power configuration, or Agent-to-Workspace bindings. CLI and Desktop read the same files and database.
`agent_id` and `workspace_id` are independent global identities. A Session receives a Workspace only for a concrete execution. CLI City daemon state lives in `~/.downcity/runtimes/city/`; Sessions live under `~/.downcity/agents//sessions/`. A Session's `meta.json` records `workspace_id` only when a Workspace was supplied.
## Project assets
Global Env is stored separately in `~/.downcity/.env`. Project `.env` overrides Global Env, while explicit process environment variables have the highest priority. Project `.env` is added to `.gitignore` by default. `.agents/skills` is the only user-managed Agent capability asset intended for version control.
## Runtime data
Downcity does not create `/.downcity/`. Agent runtime data is centralized under the City-provided storage scope `agents//`; without a City, it remains in memory. Power-owned data uses the Agent-level scope exposed through PowerContext.
Continue with:
- [Project Init](/en/docs/cli/init)
- [Agent SDK](/en/docs/agent/sdk)
### Agent CLI
Source: https://downcity.ai/en/docs/cli/cli/
The `city` CLI is a local City host. It reads Agent and Power configuration, constructs Agents, Workspaces, and one instance of each Power separately, then lets City own their composition and long-running lifecycle.
## Project initialization
```bash
city agent create
```
With no path, choose the current directory or another folder through the native system picker. Relative and absolute path arguments are also supported, for example `city agent create .` and `city agent create ./my-project`.
This generates:
- `.agents/skills/`
- `.downcity/`
- `.env`
- Agent config in the CLI global DB
## Project directory structure
```text
my-project/
├── .env
├── .agents/skills/
├── .downcity/ # runtime data only
└── your application code
```
## Key commands
- `city` — open the Agent list; Federation and global Power actions are in its Settings group
- `city on` — start the CLI City and restore all bound Agents
- `city status` — inspect City, HTTP/RPC ports, and configured Agents
- `city restart` — restart City and reload configuration
- `city off` — stop the CLI City
- `city agent chat [agent_id]` — chat with an Agent even while City is off
## Chat TUI security policy
After selecting an Agent and entering its most recent conversation, use `/security` to choose the current Session security policy:
- `Default` — maps to the Session `ask` mode; every host Shell action requires explicit approval.
- `Always Allow` — maps to `always-allow`; future host Shell actions in the current Session are approved automatically.
The active policy appears in the Chat header. A policy change affects only future Shell requests in the current Session; an already pending request still requires an explicit response. `Always Allow` does not bypass dangerous-command rejection, Workspace boundaries, or other runtime safety checks.
## What the CLI does
1. Reads agent config from the CLI global DB
2. Resolves `execution.modelId` through the connected Federation AIService
3. Passes the resolved config to `new Agent(...)`
4. Reads configuration through repositories, creates Agents explicitly, and registers them with `new City(agents)`
5. Exposes Agents by `agent_id` over one HTTP port and one RPC port
## CLI vs SDK
The CLI is for day-to-day project management. The SDK is for embedding agent behavior into your own applications. Both use the same execution core.
Continue with:
- [Agent SDK](/en/docs/agent/sdk)
- [Agent Lifecycle](/en/docs/agent/lifecycle)
### CLI Commands
Source: https://downcity.ai/en/docs/reference/cli/
Downcity docs explain the design model, runtime boundaries, and configuration semantics.
`downcity` / `city` hosts local Agents, manages local projects, and connects this machine to a Federation. Federation creation, deployment, and infrastructure operations belong to `fed` / `downfed`. For concrete flags, use the local command help from the installed CLI; the City SDK docs cover the SDK-side CLI boundary.
- [CLI: fed and city](/en/city-sdk-docs/packages/cli)
## Local help
For exact flags, use local command help from the installed CLI. Product docs describe the supported command groups and common workflows.
## Interactive TUI
Running `city` or `downcity` without a subcommand now opens the interactive TUI by default. The `city` side focuses on local Agents and Federation connection state; the `fed` side focuses on Federation management and deployment. In both CLIs, the left sidebar carries menu hierarchy with a breadcrumb at the top, while loading states, forms, tables, JSON, messages, and selection details render in the right section until you exit the TUI.
## CLI language
`city` and `downcity` support both Chinese and English.
- Use `--lang zh` or `--lang en` to switch the current command explicitly.
- If `--lang` is not passed, the CLI first uses the last language selected in the interactive menu, then falls back to `DC_CLI_LANG`, `LC_ALL`, `LC_MESSAGES`, and `LANG`.
- In interactive mode, both `city` and `downcity` can change the default language from their menus and persist that preference locally.
Examples:
```bash
downcity --lang en --help
city --lang zh
DC_CLI_LANG=zh downcity agent --help
```
### .downcity Directory
Source: https://downcity.ai/en/docs/reference/dot-downcity/
`~/.downcity/` is Downcity's user-level configuration and runtime data directory. Downcity does not create a `.downcity/` directory inside each project.
## Typical layout
```text
~/.downcity/
├── agents//
│ ├── agent.json
│ ├── SOUL.md
│ ├── sessions///
│ ├── archived-sessions///
│ └── logs/
├── powers//
│ └── config.toml
├── runtimes/city/
└── downcity.db
```
## Key folders
### `agents//logs/`
Daily JSONL logs: `.jsonl`.
### `agents//sessions///messages/`
Conversation source of truth: `active.jsonl`, `agent_message.json`, and sequence-named `segments/*.jsonl`.
`category` has exactly two values, `chat` and `work`, and it is the only field that enters the directory
name. The role `type` is stored in Session metadata.
### Memory
Long-term Memory is not part of the Workspace `.downcity/` contract. Agent-private content lives under `agents//powers/memory/`; City-shared User and Workspace content lives under `memory/` and is isolated by `MemoryPower` subjects. City only supplies the shared root and does not implement Memory data logic.
### Power runtime data
Power-owned runtime data uses the Agent-level storage scope exposed by PowerContext. Power definitions and unique configuration remain under `~/.downcity/powers//`.
### Skills location
`SkillPower` scans `.agents/skills` (project) by default. If `use: ["home"]` is enabled in the constructor, it also scans `~/.agents/skills` (user).
## Security guidance
- `~/.downcity/` may contain sensitive configuration, conversations, tokens, and execution traces.
- Do not commit or copy the directory to an untrusted location.
### HTTP API Reference
Source: https://downcity.ai/en/docs/reference/api/
> User-facing runtime API. For most operations, CLI (`downcity ...`) is recommended first.
## Core endpoints
### `GET /health`
```json
{ "status": "ok", "timestamp": "2026-03-08T12:00:00.000Z" }
```
### `GET /api/status`
```json
{ "name": "downcity", "status": "running", "timestamp": "..." }
```
### `POST /api/execute`
Request body:
```json
{
"instructions": "Summarize today's changes",
"chatId": "default"
}
```
## Managed power API
### `GET /api/powers/list`
List managed power states.
## Power API
### `GET /api/powers/catalog`
List registered powers.
### `POST /api/powers/availability`
```json
{ "power_name": "sound" }
```
### `POST /api/powers/action`
```json
{
"power_name": "sound",
"action_name": "models",
"payload": { "capability": "asr" }
}
```
## Console UI API (summary)
### `GET /api/ui/powers`
Returns power summary plus availability state (used by Console UI panels).
Example:
```json
{
"success": true,
"powers": [
{
"name": "sound",
"actions": ["models", "asr", "tts"],
"pipelines": ["chat.augmentInbound"],
"guards": [],
"effects": [],
"resolves": [],
"hasSystem": true,
"hasAvailability": true,
"availability": {
"enabled": true,
"available": true,
"reasons": []
},
"config": {
"actions": [
{
"name": "models",
"supportsCommand": false,
"supportsApi": false,
"apiMethod": "",
"apiPath": ""
}
]
}
}
]
}
```
### `POST /api/dashboard/sessions/:sessionId/execute`
Execute one turn in a specific session (for example a selected chatKey).
Request body example with attachment:
```json
{
"instructions": "Please read the attached markdown and provide an actionable summary.",
"attachments": [
{
"type": "document",
"fileName": "page.md",
"contentType": "text/markdown; charset=utf-8",
"content": "# Captured Web Page\n\n..."
}
]
}
```
Notes:
- `sessionId` should be the exact target session returned by agent. For chat sessions, this is an opaque ID managed by the chat power (not a parseable platform key).
- Attachments are materialized by agent and injected as `` tags before the user instruction.
- If `sessionId` maps to a chat conversation, execution is enqueued into the same chat power queue (same behavior as platform inbound messages), and chat history is appended before execution.
- Chat-context calls return immediately with queue metadata (`queued=true`, `queueItemId`, `queuePosition`).
## Action routes
Power actions use:
- `/api/powers/action`
Except for `GET /health`, Agent HTTP APIs require a Bearer Token issued for that Agent. Tokens do not contain Users, Roles, or Permissions; they only grant access to their owning Agent.
## Debug baseline
Make sure the agent is running before calling APIs. Runtime startup belongs to the [Agent start workflow](/en/docs/reference/cli); the health endpoint belongs to the running Agent HTTP surface.
### Contributing
Source: https://downcity.ai/en/docs/community/contributing/
Thank you for your interest in contributing to Downcity! This guide will help you get started.
## Ways to Contribute
### 🐛 Report Bugs
Found a bug? Report it:
1. Search existing issues first
2. Create a bug report with:
- Clear title and description
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Node version, etc.)
- Screenshots if applicable
### 💡 Suggest Features
Have an idea? Propose it:
1. Check roadmap and existing discussions
2. Create a feature request with:
- Problem statement
- Proposed solution
- Use cases
- Potential implementation
### 📖 Improve Documentation
Docs are code too! You can:
- Fix typos and grammar
- Add examples
- Clarify confusing sections
- Translate to other languages
- Add new guides
### 🔧 Code Contributions
Ready to code? Great!
1. Start with good first issues (tagged `good-first-issue`)
2. Follow the contributing guide below
3. Submit a pull request
---
## Development Setup
### Prerequisites
- Node.js >= 18
- pnpm (recommended) or npm
- Git
### Fork and Clone
```bash
# Fork the repository on GitHub
# Clone your fork
git clone https://github.com/YOUR_USERNAME/downcity.git
cd downcity
# Add upstream remote
git remote add upstream https://github.com/genesiscosmos/downcity.git
```
### Install Dependencies
```bash
# Install dependencies
pnpm install
# Setup development environment
pnpm dev:setup
```
### Build and Test
```bash
# Build the project
pnpm build
# Run tests
pnpm test
# Run linter
pnpm lint
# Type check
pnpm typecheck
```
---
## Project Structure
```
downcity/
├── packages/
│ ├── cli/ # CLI tool
│ ├── core/ # Core agent logic
│ ├── llm/ # LLM provider implementations
│ └── tools/ # Built-in tools
├── private/apps/homepage/ # Documentation site
└── examples/ # Example configurations
```
---
## Making Changes
### Branch Strategy
```bash
# Create a feature branch
git checkout -b feature/my-feature
# Or for bug fix
git checkout -b fix/issue-123
# Or documentation
git checkout -b docs/update-guide
```
### Commit Messages
Follow conventional commits:
```
feat: add support for custom tools
fix: resolve race condition in task scheduler
docs: update installation guide
chore: upgrade dependencies
test: add tests for permission system
refactor: improve LLM provider abstraction
```
### Code Style
Follow the project's code style:
```bash
# Run formatter
pnpm format
# Run linter
pnpm lint:fix
# Type check
pnpm typecheck
```
---
## Pull Request Process
### Before Submitting
1. **Run tests**: `pnpm test`
2. **Format code**: `pnpm format`
3. **Check types**: `pnpm typecheck`
4. **Update docs**: If applicable
5. **Add tests**: For new features
### Submitting PR
1. Push to your fork
2. Create pull request to `genesiscosmos/downcity:main`
3. Fill PR template:
- Description of changes
- Related issues
- Testing steps
- Breaking changes (if any)
### Review Process
Maintainers will review your PR and provide feedback. Address review comments and push updates to your branch.
### Merging
Once approved, your PR will be merged. Maintainers will:
- Squash commits if needed
- Update changelog
- Deploy to production
---
## Coding Standards
### TypeScript
- Use strict mode
- Avoid `any` type
- Prefer `const`/`let` over `var`
- Use arrow functions for callbacks
- Document public APIs
- In `cli/city/src`, you can use `@/` path aliases (mapped to `src`), and `pnpm build` will rewrite them to runnable relative paths in `bin`
### Testing
- Write unit tests for new features
- Aim for >80% code coverage
- Mock external dependencies
- Test edge cases
### Documentation
- Document public APIs
- Add JSDoc comments
- Include usage examples
- Update guides if behavior changes
---
## Development Workflow
### Example Feature Development
```bash
# 1. Create branch
git checkout -b feature/add-discord-power
# 2. Make changes
# ... edit files ...
# 3. Commit changes
git add .
git commit -m "feat: add Discord bot power"
# 4. Test
pnpm test
# 5. Push to fork
git push origin feature/add-discord-power
# 6. Create PR on GitHub
# Visit your fork and create pull request
```
### Syncing with Upstream
Keep your fork up-to-date:
```bash
# Fetch upstream
git fetch upstream
# Merge upstream main
git checkout main
git merge upstream/main
# Push to your fork
git push origin main
```
---
## Community Guidelines
### Code of Conduct
- Be respectful and inclusive
- Provide constructive feedback
- Accept feedback gracefully
- Focus on what is best for the community
### Getting Help
- Ask questions in Discord
- Start discussions for complex topics
- Request reviews early (WIP PRs)
### Recognition
Contributors are recognized in:
- Release notes
- Contributors section
- Annual community highlights
---
## Developer Certificate of Origin
By contributing, you agree to the DCO:
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1. The making of contributions and patches to the Project by
developers and other stakeholders is the intended method
of cooperation and collaboration for the Project.
2. The Developer Certifies that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open
source license and I have the right under that license to
submit that work with modifications, whether created in whole
or in part by me, under the same open source license (unless
I am permitted to submit under a different license), as
indicated in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including
all personal information I submit with it, including my
sign-off) is maintained indefinitely and may be redistributed
consistent with this project or the open source license(s)
involved.
```
Add this to your commits:
```bash
git commit -s -m "feat: add new feature"
```
---
## Project-Specific Guidelines
### Core Package Changes
For changes to `packages/agent`:
- Add tests for new features
- Update architecture docs if needed
- Consider backward compatibility
### CLI Changes
For changes to `cli/city`:
- Update CLI help text
- Add usage examples
- Document new commands
### Documentation Changes
For changes to `private/apps/homepage/`:
- Test locally: `pnpm dev`
- Check for broken links
- Update navigation if needed
---
## Release Process
Maintainers follow this process:
1. Update version in `package.json`
2. Update CHANGELOG.md
3. Create release branch
4. Test thoroughly
5. Tag release
6. Build and publish
7. Create GitHub release
8. Announce on community channels
---
## Resources
### Documentation
- [Architecture](/en/docs/concepts/architecture) - System architecture
- [Power Commands](/en/docs/reference/cli) - User-facing power command docs
- [API Reference](/en/docs/reference/api) - Runtime HTTP API docs
### Community
- [Discord](https://discord.gg/downcity) - Real-time chat
- [Telegram Community](https://t.me/+iozIHyXr-BJhNjE1) - Q&A
- [GitHub Issues](https://github.com/genesiscosmos/downcity/issues) - Bug reports
### Related Projects
- [Fumadocs](https://fumadocs.com) - Documentation framework
- [DeepSeek](https://www.deepseek.com) - AI model
- [OpenAI GPT](https://openai.com) - AI model
---
## Recognition
Thank you for contributing! Your help makes Downcity better for everyone.
Contributors are recognized in:
- [Contributors](https://github.com/genesiscosmos/downcity/graphs/contributors)
- Release notes
- Annual highlights
---
## Next Steps
Ready to contribute?
1. Look for [good first issues](https://github.com/genesiscosmos/downcity/labels/good-first-issue)
2. Join the [Discord community](https://discord.gg/downcity)
3. Start coding! 🚀
### Downcity Docs
Source: https://downcity.ai/zh/docs/
Downcity 是给 AI builders 的 Agent 基础设施。这套文档帮助你运行本机 Agent、接入 City、连接模型和 Power,并把 Agent 工作流做成真实产品表层。
最短产品模型:
- 产品负责用户体验
- Agent 在明确运行边界里执行任务
- Downcity 提供模型、工具、任务、记忆、服务、权限、用量、计费和控制台这些可复用运行层
## 从这里开始
## 运行与控制
- [Agent](/zh/docs/agent/overview/):执行能力与运行边界。
- [Power](/zh/docs/power/overview/):工具与集成的组织方式。
- [City](/zh/docs/city/overview/):宿主、资源和工作空间。
- [Federation & Service](/zh/docs/federation/overview/):共享模型与产品服务。
- [安全](/zh/docs/security/overview/):权限、审批与隔离边界。
- [CLI](/zh/docs/cli/overview/):在终端管理本机运行环境。
## 文档 Space
- [Products](/zh/docs/):产品级文档:Agent、Power、City、Federation、安全和参考。
- [Agent SDK](/zh/agent-sdk-docs/):可编程 Agent、Session、Local/Remote Agent、模型绑定和 Power API。
- [City SDK](/zh/city-sdk-docs/):`City`、`Federation`、downcity 身份、token、service/action 基建和部署。
- [Payments](/zh/payments/):accounts、balance、usage、Stripe payment services。
- [Agent Powers Docs](/zh/powers-docs/):内建 Power 手册和 Power 示例。
- [UI SDK](/zh/ui-sdk-docs/):可复用 UI 组件和 Workboard 界面。
### 快速开始
Source: https://downcity.ai/zh/docs/quickstart/
Downcity 有多个用户表层。这个页面是入口地图,不是命令教程。
你可以按目标选择入口:
- 想运行本地 Agent、管理项目 Agent、使用内建 Power 命令,看 [Downcity CLI](/zh/docs/reference/cli)。
- 想创建、管理或部署 Federation 项目,看 [CLI](/zh/city-sdk-docs/packages/cli) 里的 `fed/downfed`。
- 想把 Agent 能力嵌入自己的 Node.js 应用,看 [Agent SDK](/zh/agent-sdk-docs)。
- 想构建 service、模型供应、计费、usage 或 admin 表层,看 [City SDK](/zh/city-sdk-docs)。
- 想理解内建 Power 行为或编写 Power 集成,看 [Agent Powers Docs](/zh/powers-docs)。
- 想查 Downcity CLI、Downcity Console、Chrome Extension 的产品用法,看 [Products Docs](/zh/docs)。
## 推荐阅读顺序
先理解模型:
1. [Downcity 的整体关系](/zh/docs/concepts/project-logic-overview)
2. [Agent 总览](/zh/docs/agent/overview/what-is-agent)
3. [配置模型](/zh/docs/configuration/overview)
再选择具体使用表层:
- 本地 CLI 使用:[Downcity CLI](/zh/docs/reference/cli)
- 项目 Agent 工作流:[Agent CLI 产品文档](/zh/docs/reference/cli)
- Power 工作流:[Power CLI 产品文档](/zh/docs/reference/cli)
- Federation / 本机 Agent 命令:[CLI](/zh/city-sdk-docs/packages/cli)
- SDK 集成:[Agent SDK](/zh/agent-sdk-docs)
## 这个 Space 解释什么
Downcity docs 解释为什么系统要拆成 Downcity、City、Agent、Powers、services、permissions 和 configuration 这些层。
具体命令参数、示例和一步步操作指南,放在产品文档或 SDK 文档里。
### Desktop 消息
Source: https://downcity.ai/zh/docs/desktop/
{/* Desktop 面向用户的消息阅读、文件预览与折叠操作说明。 */}
## 测试 System One 决策模型
在 **设置 → 模型** 的决策模型区域点击 JEV 或其他决策模型,在详情中使用 **测试决策模型**。表单预填了可直接运行的示例;修改待判断内容与判断问题,选择布尔判断、选项判断或评分,再点击 **运行测试**。选项判断每行填写一个不重复的候选项(2–255 项);评分需要填写上下界,区间宽度为 1–9。切换答案类型时,请同步调整判断问题。
结果展示结构化答案、上游提供的概率或选项分布、用量以及本次耗时。失败时在表单内显示错误,可以修改输入后重试。测试使用当前账户,按模型正常计费;关闭详情不会取消已经提交的请求。
## City Remote
在 **设置 → 通用 → City Remote** 启用 HTTP 连接,默认监听 `127.0.0.1:5314`。开关直接生效;修改地址或端口后点击保存更改。页面显示实际运行状态、连接地址及启动失败原因。把运行中的地址复制到 Chrome 扩展的 City URL,无需另行执行 `city on`。
在同一页创建 City Token,填写名称、选择允许的 Agents(或明确允许全部现有及未来 Agent)和过期时间。点击创建 Token,在弹窗中选择 30 天、90 天、永不过期或自定义日期。明文仅在创建后的独立弹窗中显示一次,复制到 Chrome 的访问凭据后点击完成。已有凭据可修改范围、有效期或撤销;CLI 的 `city token` 管理的是同一份凭据。
服务默认关闭,开启设置会在下次 Desktop 启动时恢复。关闭开关、修改监听设置或退出 Desktop 会断开远程连接。Desktop 与 CLI 仍采用单宿主机制,不需要同时启动。`127.0.0.1` 仅供本机连接;监听其他地址时使用对应机器地址,并自行配置所需的网络访问。
## 粘贴图片与附件保存
粘贴图片后,输入草稿中的附件以 Data URL 保存在本地 IndexedDB。发送时,Session 会将附件解码保存到自己的 `attachments` 目录,消息的 file part 记录本地绝对路径,并紧跟一个 `attachment_path` context part,以“对应图片路径:…”(其他文件为“对应附件路径:…”)向模型提供保存路径。
## 连续发送与接管
在 Chat 中发送消息时,只有会话空闲且待发送列表为空才会立即提交;当前一轮仍在执行,或待发送列表里还有消息时,新输入会追加到待发送列表末尾,等上一个 Turn 结束后按顺序逐条提交,不会越过前面排队的内容。
列表中的消息在真正提交前只是本地记录:可以编辑、删除、调整顺序,或逐条暂停。`⌥/Alt + ⌘/Ctrl + Enter` 直接追加一条暂停项,不会自动发送;`⌘/Ctrl + Shift + Enter` 绕过整个列表立即提交当前输入,不影响列表中的其他消息。提交失败的消息保留在列表中并转为暂停,修复后可以重试。
每一轮工作由发起者占用。同一用户可以持续补充;如果当前工作由其他对象发起,队列提交会被拒绝,消息保留在待发送列表中并转为暂停,不会丢弃。需要接管时,先使用停止按钮,等待当前工作停止,再发送自己的消息。停止只针对界面已显示的那一轮;状态已变化时请刷新后重试。提交被拒绝不会把原有工作标为执行失败。
停止前登记的完成通知仍会携带原任务的停止结果送达,不会使用接管后新一轮的回答。
## 回答问题与操作确认
Agent 的待回答问题和操作审批集中显示在聊天输入框上方,单聊与群聊使用相同入口。交互卡片使用统一浅色背景和圆角,多条待处理项通过左右箭头切换;群聊会标出当前发起的成员。切换待处理项或折叠消息中的工具详情不会清空已填写内容,多题问题也可通过左右箭头返回修改。补充说明按需展开填写。
长内容在待处理区内滚动,聊天输入框保持可见。提交成功后,该项从待处理区移出;提交失败会显示错误并保留内容,可直接重试。工具详情只展示工具执行信息,不重复展示审批卡片或“已允许”等审批结果。待处理交互统一在输入框上方操作,不再提供独立的右侧作答面板。
## 手动压缩上下文
在已有单聊 Session 的输入框中输入 `/`,选择 `/compact`,即可将较早的稳定对话内容合并为上下文摘要,原始聊天记录仍然保留。会话运行中也可以提交;压缩请求进入 Session 队列,在维护检查点按队列顺序执行。压缩与 prompt 共用 Session 队列的忙碌状态,执行期间会话加载指示持续显示,队列全部排空后才结束;连续排队不会在命令之间提前显示空闲。压缩过程与结果显示在聊天时间线中,没有可压缩内容时也会给出提示。新对话草稿不显示该命令。
摘要历史以纯文本传入:用户和 Agent 保留正文,工具只保留结果,省略调用参数、调用 ID、协议状态和 JSON 包装;结构化结果按缩进键值呈现,保留必要的字段含义。压缩与正常对话共用模型可见内容的转换规则,忽略界面展示数据、活动记录和推理过程;附件保留引用与元信息,不重新读取二进制内容。工具结果中嵌套的图片 data URL 和明确的 base64 字段也只保留媒体类型与大小等元信息,同级页面文字、路径和 URL 继续保留;原始工具记录不变。系统以压缩较早稳定内容约 65% 为目标,在完整内容边界选择最近的位置;至少保留 10,000 tokens 的原始历史,此要求优先于比例。历史不超过该保留量,或没有可推进的完整内容时不压缩。保留量不包含 system、旧摘要和工具定义。每次压缩将旧摘要、本次所选的每条历史记录和最终摘要要求分别放入多条纯文本消息,一次请求模型生成新摘要;历史保持原顺序,不再拼成单条超长 user 消息,也不分批反复重写。没有固定的 16k 或 64k 输入限制;已知模型窗口时预留 4,000 tokens 输出和 2,048 tokens 安全余量,放不下时只缩小本次历史范围。窗口未知时不设置人为输入上限。当前模型接口没有 tokenizer,英文、代码与 JSON 按每三 ASCII 字符约一 token、其他字符按码点估算;连续的长编码串按更高 token 密度估算,避免当作普通英文文本。以上并非精确 token 计数。单次摘要输出上限为 4,000 tokens。
完整摘要生成成功后一次保存新摘要与边界;失败保持原摘要和边界不变。传输中断时最多重试同一请求一次,部分生成但尚未保存的摘要会丢弃。HTTP 400 等请求拒绝直接反馈,不自动当作上下文超限。使用新版 Federation 服务时,错误还会包含上游状态码、可用请求 ID 和经过脱敏、长度限制的响应正文,方便定位具体拒绝原因。
## 窗口与面板
左侧 Sidebar、中间正文和右侧 BayBar 共享窗口空间。调整窗口大小时,Sidebar 优先保持你设置的宽度,正文先随窗口伸缩;空间不足时,BayBar 会先收窄,再自动收起。正文阅读空间继续不足时,Sidebar 也会自动隐藏。默认宽度和 100% 界面缩放下,窗口窄于 860px 时隐藏 Sidebar,重新达到 908px 时恢复;你设置的 Sidebar 宽度和界面缩放会同步调整该阈值。关闭的面板会释放全部占宽。
拖动面板与正文之间的边界可以调整宽度,松开后保存。临时缩小窗口不会覆盖已保存的宽度;重新放大窗口后,自动收起的面板会在空间足够时恢复,手动收起的面板保持关闭。窄窗口中主动打开 BayBar 或点击文件链接时,它会覆盖右侧内容区域,调整窗口大小后重新按可用空间布局。
Sidebar 自动隐藏或手动收起后,将鼠标移到窗口左边缘或顶部 Sidebar 按钮,停留约 150ms 即可浮出导航。浮动导航覆盖在正文上方,正文位置与宽度保持不变。移开鼠标约 250ms 后收起;操作关联菜单、拖拽宽度或在导航内使用键盘时保持展开。点击顶部按钮可固定展开;空间不足时保持浮动,点击外部或按 `Escape` 关闭。
使用窗口顶部按钮或 `⌘/Ctrl+B` 开关 Sidebar,使用右上角按钮或 `⌘/Ctrl+L` 开关 BayBar。窗口缩放和面板拖拽立即跟随操作,点击开关时使用短动画;系统开启减少动态效果时省略该动画。
## 会话层级
Works 侧栏的 Workspace 分组内,子 Session 紧跟所属的父 Session,并逐级缩进。根会话与同级子会话按运行状态优先、最近更新时间其次排列。父会话被置顶或不在当前 Workspace 中时,子会话仍会作为根会话显示;置顶区保留手动置顶顺序。归档父会话时,其所有层级的子会话也会一起归档;如果其中任一会话正在运行或有排队输入,需要先结束或停止执行。
每个父会话默认最多显示 3 个直接子会话,超出时在第 4 行显示“更多(还有 N 条)”。点击可展开全部子会话,再点击末尾的“收起子会话”恢复为 3 个。多层子会话分别控制展开状态;Workspace 的“更多”按根会话分页,多选范围只包含实际显示的会话。
## 置顶会话
在 Works 侧栏中,会话行的更多菜单里选择**置顶**,这条会话会移到列表最上方的**置顶**区,不随任何 Workspace 折叠。置顶区按置顶先后排列,新置顶的在最前;它在行尾标出所属 Workspace。Chat 页头的会话菜单里有同一项,显示为**取消置顶**。置顶只用于 Works 侧栏里的会话,群聊会话同样可以置顶。
## 分享消息与会话
分享图片和 PDF 在文档页首显示 Downcity Logo 与品牌名称,预览和保存结果一致。
在已完成的 Agent 回复下方打开更多菜单,选择**分享**,在预览中选择**保存为图片**、**保存为 PDF**或**保存为 Markdown**,包含 Agent 名称、发送时间和完整正文。
在 Chat Session 右上角的更多菜单中选择**分享**,再选择**保存为图片**、**保存为 PDF**或**保存为 Markdown**。导出会读取完整历史,按原始顺序包含用户消息和 Agent 回复;思考过程、工具调用记录和内部上下文不包含在内。Markdown 保留可编辑正文和附件链接;图片与 PDF 保留代码块、表格、数学公式、图片以及内置可视化的静态展示。第三方或无法渲染的可视化使用已发布的文字摘要。
点击分享后先打开预览。顶部通过分段按钮切换图片、PDF 和 Markdown,中间画布居中展示内容,底部集中显示内容开关、复制和保存操作。图片随窗口等比缩放;长图的全部分段按顺序连续滚动展示。
分享单条 Agent 回复时,如果存在上一条用户消息,可开启**包含上一条用户消息**,将其发送者、时间、正文和附件放在回复之前。默认关闭;切换选项会更新预览,图片、PDF 和 Markdown 保存相同范围的内容。上一条用户消息位于更早历史时也支持,无需先手动加载聊天记录。
PNG 按实际图片分段展示,底部的**复制图片**会将当前主要可见的那张图片复制到剪贴板,无需先保存文件;PDF 可查看分页,Markdown 展示待保存的源码。确认后点击对应格式的**保存为…**按钮,才会打开系统保存框;取消保存仍保留预览,关闭预览会释放准备的内容。资源加载失败会在预览中提示。
短图片选择文件保存位置;长内容会提示选择目录,并在其中新建文件夹,按顺序保存多张 PNG。PDF 自动分页,文字可选择。完成后可直接打开文件或图片目录;取消保存不会产生导出文件。图片资源读取失败时会提示错误,可以处理资源后重新导出。
## 消息与文件
点击对话中的工作区文件链接,会在右侧文件面板打开预览,当前对话保持可见。指向其他已注册工作区的文件也在该面板打开;链接带有行号时会定位到对应行。网页链接通过默认浏览器打开。
文件面板与 Workspace 主视图按格式选择预览方式:Markdown 展示排版后的文档;本地 `.html`、`.htm` 展示静态网页,支持相对路径的样式表、字体和图片,禁用脚本与页面内跳转。CSV、TSV 展示带行列编号的表格,保留引号内的分隔符与换行,最多展示 1,000 行、100 列。Markdown、HTML、CSV、TSV 均可通过文件菜单中的**源码模式**查看完整原始文本;带行号的文本链接默认进入源码模式。
Markdown 文件中的相对图片路径(如 `images/chart.svg` 或 `../images/chart.png`)按当前文档所在目录解析,BayBar 文件面板与 Workspace 预览均支持。
Markdown 中标记为 `mermaid` 的围栏代码块会显示为图表,并随可用宽度调整布局。图表跟随当前主题,可复制源码或打开全屏查看;全屏支持缩放、拖拽、适应窗口和下载保留当前主题的 PNG。生成中的不完整语法会保留最近一次成功图表,生成结束仍无法解析时显示源码与重试按钮。
Markdown 中标记为 `svg` 的围栏代码块会直接显示为图片,适配可用宽度;BayBar 文件预览与聊天消息均支持。流式生成尚未形成完整 SVG、内容无效或图片加载失败时显示源码。SVG 以隔离图片方式加载,不执行脚本。
PNG、JPEG、GIF、WebP、SVG、AVIF、APNG、BMP、ICO 文件以图片展示,可切换适配面板或原始尺寸。图片独立加载,不经过文本读取接口。文本预览仍限制为 2 MB;代码与配置文件展示源码。
在右侧 BayBar 文件面板中选中文字,选区附近会出现**引用**浮动按钮。点击后,当前聊天输入框会加入包含工作区、文件路径和选中文字的引用;源码模式还会附带选区行号。引用保留点击时的内容,可以随草稿保存或在发送前移除。清空选区、滚动、切换文件或更新文件内容后,浮动按钮会收起。Markdown、表格预览和源码支持该操作;HTML 网页预览需要先切换到源码模式,图片不提供文字选区引用。
Agent 消息采用左右布局:左侧竖排头像与可选折叠按钮,右侧顶部信息栏显示 Agent 名称和时间,中间展示消息主体,下方展示文件改动。信息栏与主体之间保留间距。复制、引用和分支等消息操作位于全部内容下方;有文件改动时,操作栏显示在文件改动卡片之后。
有执行过程时,头像下方显示箭头折叠按钮,与头像竖向排列。按钮常显,悬停时显示浅色背景;收起时箭头朝右,展开时朝下。点击按钮可展开或收起执行过程,也可以通过键盘操作。
生成过程中,执行过程默认展开;完成后默认收起。收起时隐藏过程,最终回答保持完整可见,不再显示工具摘要折叠栏。只有执行过程而没有最终回答时,左侧箭头仍可用于重新展开。
展开与收起以按钮为滚动锚点。手动切换后,这条消息会保留你的选择。没有执行过程的消息直接展示回答,不显示折叠按钮。
### Chrome 扩展
Source: https://downcity.ai/zh/docs/products/chrome-extension/
Downcity Chrome 扩展支持在侧栏与 Agent 对话、引用当前网页和选中文本。需要 Chrome 116 或更新版本,以及已启动、可访问的 Downcity City。
[下载 Chrome 扩展 ZIP](/download/chrome-extension)
当前通过 ZIP 分发,需手动安装,尚未上架 Chrome Web Store。安装包适用于 macOS、Windows 和 Linux 上的 Chrome。
## 手动安装
1. 下载 ZIP,解压到一个长期保留的目录,例如 `Documents/Downcity/chrome-extension`。
2. 在 Chrome 地址栏输入 `chrome://extensions`。
3. 打开右上角的 **开发者模式**。
4. 点击 **加载已解压的扩展程序**。
5. 选择直接包含 `manifest.json` 的解压文件夹。
6. 在浏览器工具栏中固定 Downcity 扩展,点击图标打开侧栏。
不要选择 ZIP 文件,也不要在加载后删除解压目录。
## 连接 Downcity
先启动 City,再打开扩展的 **设置**,填写:
| 字段 | 内容 |
| --- | --- |
| City URL | City 的 HTTP 地址,默认 `http://127.0.0.1:5314`;支持反向代理路径前缀 |
| 访问凭据 | City 接受的 Bearer Token;按服务端鉴权要求填写 |
| 默认 Agent | 点击连接 City 后,从可访问列表中选择,无需手填 ID |
| Default Ask | 从网页分享入口发送时使用的默认请求 |
在运行 City 的机器执行 `city token`,交互式创建凭据,选择允许访问的 Agents 和有效期;也可使用 `city token create --name chrome --agents --never-expires`。将签发时显示的明文直接填入访问凭据,旧 Agent Token 不再接受。
先点击 **连接 City / 刷新 Agent 列表**,选择默认 Agent,再点击 **保存并检查**。成功后关闭并重新打开侧栏,或点击 **重新连接**。列表为空时请检查凭据及 City 中是否已有 Agent。
`15314` 是 RPC 端口,不能作为 City URL。City URL 无需包含 `/agents/...`,扩展会根据 Agent ID 自动构造地址。Chrome 会话使用 `chrome` 来源分区,不绑定 Workspace。
## 使用
- 点击工具栏图标,或使用 `Command+I`(macOS)/ `Ctrl+I`(Windows、Linux)打开侧栏;如快捷键冲突,可在 `chrome://extensions/shortcuts` 调整。
- 输入请求并发送。输入框默认引用当前网页,删除该引用后不预先发送网页正文;本轮 Agent 仍可通过网页 Power 按需读取。
- 在普通网页中选中文本,点击选区旁的 **引用**,将选区加入输入框。
- Agent 回复支持 Markdown、推理文本和工具执行状态。
- 关闭再打开侧栏会恢复当前 City、Agent 对应的 Chrome 会话。
- **新对话** 会创建新的会话;之前的会话仍保留在 Downcity 中。
- 实时连接断开时点击 **重新连接**,重新加载已保存的消息。
侧栏发送会为本轮提供 `chrome_page` Power,Agent 可以调用 `read_page` 读取正文,或调用 `find_text` 查找关键词。能力固定到发送时的标签页和 URL,导航后拒绝读取;不提供点击、填写或跨标签页操作。请保持侧栏打开直到本轮完成,关闭或断线会中断客户端能力。Chrome 内部页面、Chrome Web Store 和部分受保护页面无法读取。需要人工授权的工具操作会显示等待状态,请在 Downcity 中处理。
## 更新与卸载
手动安装的扩展不会自动更新。下载新 ZIP,将内容替换到原目录,在 `chrome://extensions` 点击扩展卡片上的重新加载按钮,然后刷新需要引用的网页。
当前源码版使用 City、Agent 和 Token 配置,旧版 Workspace 会话不会自动迁入 Chrome 会话;服务端历史不会删除。下载包是否包含此能力取决于已发布版本。
在 `chrome://extensions` 点击 **移除** 可卸载扩展。浏览器配置会随卸载移除,服务端会话不会被删除。
## 下载校验
[当前版本元数据](/download/chrome-extension/latest.json) 提供 ZIP 的版本、文件大小与 SHA-256。下载地址最终会跳转到带版本号的 ZIP;同一路径追加 `.sha256` 即可下载校验文件。
[扩展数据与隐私说明](/zh/docs/products/chrome-extension-privacy)
### Chrome 扩展数据与隐私
Source: https://downcity.ai/zh/docs/products/chrome-extension-privacy/
本说明适用于包含请求级网页 Power 的当前源码版本,更新日期:2026-10-20。
## 读取和发送
扩展为显示当前页面引用而读取活动标签页的标题和 URL。网页中的选区脚本在本地检测选中文本,点击“引用”后将选区传入侧栏。
点击发送时,扩展把用户输入以及本次引用的正文、标题、URL 或选区发送到用户配置的 City 下指定 Agent。侧栏发送还会提供仅对当前 Turn 有效的网页读取与文本查找能力,固定到发送时的标签页和 URL;Agent 调用时,读取结果会发送到该 City。删除网页引用只取消预先发送正文,不撤销本轮按需读取能力。网页内容可能包含登录后可见的私人信息,请只在希望授权 Agent 阅读的页面发送。页面导航后拒绝读取,关闭侧栏会中断执行通道。
City 会按照用户配置执行 Agent 请求,内容可能被提供给用户配置的模型服务或工具。服务器侧的保存期限、模型提供方和工具行为取决于该 City 的配置。
## 本机保存
扩展使用 Chrome `storage.local` 保存 City URL、Agent ID、Agent Token、默认请求、服务端会话 ID、网页分享入口的最近发送记录,以及短期选区引用。Token 不写入 `storage.sync`。
发送记录包含网页 URL、请求文本和时间,不包含抓取的网页正文。卸载扩展会移除浏览器端配置;服务端会话需要在 Downcity 中单独管理。
## 权限用途
| 权限 | 用途 |
| --- | --- |
| `tabs` | 获取活动标签页标题、URL,以及响应标签页切换 |
| `activeTab`、`scripting` | 在发送网页引用或本轮 Agent 调用网页读取、查找 Action 时读取绑定页面内容 |
| `sidePanel` | 展示常驻对话侧栏 |
| `storage` | 保存本机设置、会话身份和短期引用 |
| HTTP/HTTPS host permissions | 连接用户自选的本机或远程 City,并在普通网页提供选区引用 |
扩展本身不包含广告、分析埋点或独立遥测上传,也不出售用户数据。官网安装包下载请求由 Downcity 的网站和 Cloudflare 处理,属于网站访问行为。
如有隐私问题,请通过 [Downcity GitHub Issues](https://github.com/genesiscosmos/downcity/issues) 联系维护者;公开反馈时请勿包含 Token 或私人网页内容。
### 官网账户
Source: https://downcity.ai/zh/docs/website-account/
{/* 官网账户的用户操作说明。 */}
点击官网导航中的 **登录**,选择当前可用的 GitHub 或 Google 登录方式。原登录弹窗会显示授权码,并打开授权窗口。完成第三方认证后,将原弹窗中的授权码输入确认页;确认授权后,原页面会自动继续并返回你之前浏览的页面。
如果授权窗口没有打开,请允许 Downcity 官网打开弹出窗口后重试。等待期间可以取消登录;登录过期或失败时,重新选择登录方式即可。
登录后,点击导航中的账户名称,选择 **账户信息**,可以查看名称、邮箱和用户 ID。第三方账户未提供邮箱时,页面会显示“未提供”。
## 余额与使用情况
用户中心采用左右布局,左侧可切换 **账户概览**、**用量与活动**、**账务记录**、**API Keys**、**调用记录**、**模型与接入** 和 **账户资料**。手机使用顶部栏目菜单。栏目通过 URL 的 `view` 参数定位,切换时复用已读取数据;刷新时保留当前内容。
用户中心提供:
- 当前可用 Credits,以及永久额度、限时额度和到期时间。
- 本月截至今日的消费、AI 请求次数和已计量 Token。
- 用量与消费折线图,可切换消费(USD)、Token 和请求次数,查看最近 7、30 或 90 天的每日趋势。悬停可查看当天各指标,覆盖前日期保留为空。
- 过去 365 天的活动热力图,按 AI 请求次数着色,鼠标悬停或键盘聚焦可查看当天详情,点击固定日期,关闭详情或按 Escape 取消固定。
- 概览中的最近 3 笔账务记录;账务栏目展示最近 20 笔,点击记录展开说明和状态。
统计按浏览器时区的自然日计算。AI 请求次数包含失败或取消的执行,Token 只统计实际已计量用量;消费来自独立账务记录,不根据 Token 推算。覆盖起点以前的日期不视为零活动。
额度入账可能来自充值或活动赠送,因此记录显示为“额度入账”。官网当前提供额度说明,尚未提供直接充值流程。服务暂时不可用时,对应区域会提示错误,点击 **刷新数据** 可重新查询。
## API Key 管理
在用户中心选择 **API Keys**(也可直接打开 [Key 管理](/zh/account?view=keys))。你可以查看自己的 Key、创建 Key、编辑名称、模型 / route 权限、到期时间和消费软上限,也可以停用、恢复或永久撤销 Key。
权限选项来自服务当前发布的语言模型与 route 目录。选“所有当前及未来发布的模型和 route”会同时允许以后新增的入口;不选择任何入口则不能推理。只授权某个 route 不会允许直接调用它的模型。已下线的旧权限会保留在编辑列表中,可以取消;目录读取失败时保持现有权限,刷新成功后再修改权限或创建 Key。
消费软上限以整数 Credits 填写,留空表示不限额,0 会阻断新调用。它按该 Key 自签发以来已经成功结算的累计消费检查,修改上限不会重置消费。**不预占资金**,多个并发在途请求可能让最终消费超过上限;所有 Key 共用账户余额。到期时间按浏览器本地时间填写,留空表示不自动到期。
创建成功后,完整 Key **只展示一次**,请立即复制并妥善保存。关闭弹窗、离开 Key 栏目或退出登录后,页面不会再次显示明文。复制不可用时,可选中文本手动复制。若创建时网络中断,请先刷新列表确认结果;发现新增 Key 但没有取得明文时,撤销它再重新创建。
停用可以恢复;撤销不可恢复,确认前请检查 Key 名称和前缀。停用、撤销或过期会阻断新请求,已经开始的调用仍会结算。Key 只能访问获准推理入口,不能用来登录或管理账户。OpenAI 客户端配置见 [模型中转接入指南](/zh/city-sdk-docs/service/ai/relay)。
## 调用记录
在 [调用记录](/zh/account?view=requests) 查看自己的模型中转请求,每页最多 20 条,支持按 Key 筛选、上一页、更早记录和刷新最新记录。Key 名称与前缀来自本人 Key 列表;暂不可用时显示 Key ID,登录身份调用会单独标明。
每条记录显示请求的公开模型 / route、执行状态、输入与输出 Token、已结算 Credits、耗时和结算状态。**未知 / 未提供不代表 0**;输入 Token 只有缓存与未缓存两部分均已提供时才相加,推理 Token 是输出的一部分,不额外重复计算。尚未结算、结算失败或没有费用字段时,不显示为免费。点击 **详情与尝试** 查看请求 ID、实际公开模型、计量拆分及安全的上游尝试信息,包括渠道 ID、实际上游模型、HTTP 状态和首个内容耗时;没有提示词、回答正文或渠道凭据。
当前只提供 Key 筛选,不支持后端未提供的日期、状态或模型筛选。执行前拒绝的请求可能没有记录;中断的请求也可能停留在进行中,应结合计量与结算状态判断。
## 模型与接入
[模型与接入](/zh/account?view=models) 实时读取当前可用的公开语言模型与 route,支持按名称或 ID 搜索。页面保留后端声明的上下文、推理档位和结构化价格,包括币种、计价单位、数量与适用条件;缺失字段明确标为未提供,不从标签推断工具或视觉能力,也不推算 Credits。
OpenAI Base URL 从网站服务端的 Federation 连接配置推导。选择模型或 route 以及协议后,可以复制 **OpenAI Chat Completions** 或 **Downcity 原生模型流** 的 curl 示例。示例只使用 `YOUR_DOWNCITY_API_KEY` 占位值,不读取或保存真实 Key;请在自己的安全客户端替换。原生流使用独立的 Downcity SSE 事件协议。当前不支持 Responses 和 Anthropic Messages;页面不宣称这些接口可用。
目录可见不代表调用 Key 获得权限,仍需在 Key 管理中授权相应入口。目录读取失败时显示错误,刷新后可重试;显示上次读取内容时会明确提示。
选择 **退出登录** 会撤销当前官网登录凭证,并结束当前浏览器会话。退出失败时,请在服务可用后重试。会话失效后,再次登录即可继续使用。官网退出不会退出桌面端或其他设备中的账户。
### Agent
Source: https://downcity.ai/zh/docs/agent/overview/
**Agent SDK** (`@downcity/agent`) 是执行 Agent 工作的核心运行时。Agent 负责身份、模型、指令、
Tool 与唯一的 `AgentSessions` 集合;每个 Session 可以接收一个 Workspace。`City` 持有 Workspace、
Power 唯一实例、Agent/Group 集合与 transport。
## Agent 做什么
- 读取项目配置(构造参数或项目目录)
- 维护会话和对话历史
- 在执行过程中调用工具
- 使用 City 投影到当前作用域的 Power 能力
- 绑定到模型进行推理
## 创建 Agent
在 Desktop 中点击“创建 Agent”会进入独立创建页面。你可以用自然语言描述想要的角色,让系统默认模型自动生成名称、简介、角色指令和推荐 Powers;也可以选择“手动配置”,从空白草稿开始填写。AI 只负责生成可编辑草稿,确认后才会真正创建 Agent。
创建时不需要手动设计内部 ID。Desktop 会根据名称生成稳定 ID;例如“研究助手”会生成 `yan_jiu_zhu_shou`。
AI 生成和 Agent 运行模型都默认选中 Desktop 设置中的系统默认文本模型,也可以分别调整;仅当默认模型不可用时才回退到第一个可用文本模型。Desktop 页面统一展示 Agent 名称,稳定 ID 只用于内部路由和存储。
名称和简介用于联系人列表与对话中的身份展示。内部 ID 继续用于本地目录、API 引用和运行时身份;如果名称生成的 ID 已存在,Desktop 会要求换一个名称,不会隐式追加随机后缀。
创建后可以在 Agent 配置中继续修改名称和简介。修改名称不会改变稳定 Agent ID,因此已有 Session、Group 引用和 Power 数据路径保持不变。
Agent 配置中的“删除 Agent”会永久删除该 Agent 的定义、头像、Session、日志、Schedule 与 Agent 作用域的 Power 数据。正在执行任务或仍属于 Group 的 Agent 不能删除,需要先结束执行或将它移出相关 Group。
## Desktop 完成提醒
当 Agent Session 在后台完成一次 Turn 时,Desktop 会在 Chat Sidebar 的 Agent 和下方对话列表的对应 Session 上显示蓝色未读圆点,并在系统应用图标上显示未读 Session 数量。正在前台查看该 Session 时不会产生未读;打开对应 Session 后,圆点和系统角标会同步清除。多次完成同一个 Session 只计为一个未读项。
## 创建 Group
点击“创建 Group”会进入独立创建页面。你可以描述团队目标,让所选模型根据当前已有 Agent 生成 Group 名称、协作目标和推荐成员;也可以从空白配置手动选择模型与成员。AI 结果在确认前始终只是可编辑草稿。
创建 Group 时只填写用户可见名称,内部 `group_id` 由 Desktop 根据名称自动生成。创建后可以修改名称、协作目标、模型与成员,但稳定 `group_id` 不会改变。Group 头像由成员 Agent 的头像组合展示。
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const agent = new Agent({
id: "repo-helper",
model: myModel,
tools: { my_tool: myTool },
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
## 从历史消息创建 Session 分支
`session.fork()` 默认保留锚点消息。编辑并重新发送历史消息时,可以排除原消息,让新 Session 从修改后的内容继续:
```ts
const forked_session = await session.fork({
message_id,
include_message: false,
});
```
需要在当前对话中替换历史时,传入 `replace: true`:
```ts
await session.fork({ message_id, include_message: false, replace: true });
```
这会原地移除锚点及后续消息,保留当前 Session ID 与配置,不创建分支、不归档、不添加操作提示。当前 Session 必须空闲;省略 `message_id` 时不改变历史。Desktop 的“替换当前对话”使用该方式。
## 设置 Desktop Agent 头像
Desktop 会从用户级 Agent 目录读取头像。将图片放在对应 Agent ID 目录下即可:
```text
~/.downcity/agents//avatar.png
```
也支持 `avatar.jpg`、`avatar.jpeg` 和 `avatar.webp`,头像文件大小限制为 2 MiB。未配置头像时,Desktop 使用默认图标。
新建 Agent 时,Desktop 会从内置像素头像池随机选择一张并保存为该 Agent 的独立头像。在 Agent 详情页点击头像,可以再次随机选择内置头像或上传自定义图片。
## 关键概念
- **City** — 持有 Workspace、Power 唯一实例、Agent/Group 集合与统一 transport。
- **Session** — 一个执行线程。Agent 按需创建会话,并通过它们路由消息。
- **Tool** — Agent 在执行过程中可调用的函数。直接传给构造函数。
- **Power** — 由 City 提供和维护,并自动投影给全部 Agent 的扩展能力。
- **Model** — 推理后端。Agent 持有默认实例,Session 可设置自己的实例并优先使用。
## Agent 不做什么
- 不管理多个项目(那是 CLI / 控制台层)。
- 不拥有模型目录(那是 City / Federation)。
- 不持久化全局凭证(那是 City)。
保持这些边界清晰,你很容易回答:模型应该在哪配置?机器人凭证应该放在哪?这是控制平面故障还是项目运行时故障?
## 执行模型
Agent 执行模型很简单:
1. 接收消息或任务
2. 加载会话上下文(历史、工具、Power、提示词)
3. 调用模型
4. 如果模型要求调用工具,执行它并继续
5. 返回结果
继续阅读:
- [Agent SDK](/zh/docs/agent/sdk)
- [SDK 架构](/zh/docs/agent/sdk-architecture)
- [运行时架构](/zh/docs/agent/runtime-architecture)
- [Agent 生命周期](/zh/docs/agent/lifecycle)
### Agent SDK
Source: https://downcity.ai/zh/docs/agent/sdk/
`@downcity/agent` 用于在同一进程中创建 Agent、管理 Session 并执行模型调用。完整 API 参考在 [Agent SDK 文档](/zh/agent-sdk-docs)。
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
env: { API_KEY: process.env.API_KEY ?? "" },
});
const agent = new Agent({
id: "repo-helper",
model,
tools: { search: search_tool },
});
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "检查当前项目" });
const result = await turn.finished;
await agent.dispose();
```
`Workspace` 来自 `@downcity/city`,拥有项目文件、搜索工具、Env 与可选 Shell,但不拥有 Agent 身份、存储、Power 或 Session 语义。`agent.sessions.create({ workspace })` 在 City 提供的 Agent Storage 作用域内创建 SessionStore。
Agent 配置不绑定 Workspace。每个 Session 可以使用一个 Workspace;多个 Agent 也可以通过各自的 Workspace 实例操作同一物理目录。`agent.dispose()` 会释放 Agent 资源和 Session 使用的 Workspace 资源。
本地运行状态集中保存在项目目录之外:
```text
~/.downcity/agents//
├── sessions///
└── archived-sessions///
```
`category` 只有 `chat` 与 `work` 两个值,是唯一决定目录的字段;`type` 是分类内的具体角色。
不传 `origin` 时默认是 `{ category: "work", type: "work" }`。自定义角色必须同时声明分类,
恢复时通过 `get(session_id, category)` 明确读取同一个分类。详见
[Session 来源与类型](../agent-sdk-docs/sessions/origin-and-types)。
City 在添加 Power 时启动唯一实例与后台任务,Session 执行会自动等待初始化完成。关闭整个宿主
使用 `await city.close()`;只移除 Agent 使用 `city.agents.remove()`。HTTP 与 RPC 也由 City 统一提供。
## 流式消息
`subscribe()` 返回统一的实时 Session Mutation:
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
process.stdout.write(mutation.delta);
}
});
```
Tool 生命周期使用 `type: "tool"`,用户参与使用 `type: "interaction"`。pending Interaction 携带完整请求,关联 Tool 同时进入 `waiting-user`;响应通过独立 Session 命令提交:
同一 Assistant step 内的 Part 顺序始终与模型流一致。即使 Tool 已提前进入执行准备,它也会等待对应的 Tool Part 出现在流中,不会越过尚未写入的前置文本。等待按 `tool_call_id` 相互隔离,多个 Tool 仍可并发执行;客户端应按 Mutation 与历史快照中的顺序直接渲染,无需自行重排。
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
async function decide_approval(interaction_id: string, decision: "approved" | "denied") {
await session.respond({
interaction_id,
response: { type: "approval", outcome: decision === "approved" ? "resolved" : "denied", payload: { decision } },
});
}
```
历史快照使用 `session.messages()`。它按 Message 边界分页,并在存在更早历史时返回 `next_before_sequence` 游标。连接断开后重新读取快照并建立新的订阅。
需要渲染平铺活动时间线的宿主可以直接使用 SDK 投影,不需要依赖 `.downcity` 的物理文件名:
```ts
import { to_session_message_timeline_events } from "@downcity/agent";
const page = await session.messages();
const timeline = page.items.flatMap(to_session_message_timeline_events);
```
Session JSONL、日志和调度任务使用 City 注入的 StorageProvider 作用域;没有 City 时,Agent 使用进程内存储。项目 File/Search 工具无法访问这些私有状态;应用应通过 `Agent.sessions` 和 Session API 访问结构化状态。
继续阅读:
- [SDK 架构](/zh/docs/agent/sdk-architecture)
- [运行时架构](/zh/docs/agent/runtime-architecture)
- [完整 Agent SDK API 文档](/zh/agent-sdk-docs)
### Agent SDK 架构
Source: https://downcity.ai/zh/docs/agent/sdk-architecture/
Downcity 将执行主体、项目资源与扩展运行时分开。`Agent` 拥有身份、指令、模型、自定义 Tool 和
Session;`Workspace` 拥有项目资源与 Shell;`City` 持有 Power 唯一实例、生命周期与 Agent 集合。
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Shell, Workspace } from "@downcity/city";
const agent = new Agent({ id, model, instruction });
const workspace = new Workspace({ id: "project", path, shell });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
```
## 职责模型
```mermaid
flowchart TD
Host["宿主应用"] --> City["City
资源与生命周期容器"]
City --> Agent["Agent
身份、模型、指令、Session"]
City --> Workspace["Workspace
项目资源与 Shell"]
City --> Powers["Powers
唯一实例、配置投影、生命周期"]
Agent --> Enter["agent.sessions.create({ workspace })"]
Workspace --> Enter
Enter --> Sessions["AgentSessions
Session 集合"]
Sessions --> Session["Session
连续执行"]
Session --> Tools["Workspace + Agent + Power Tools"]
Session --> Context["City 投影的 PowerContext"]
Powers --> Context
City --> Storage["StorageProvider
默认 Memory"]
Session --> Storage
Workspace --> Files["项目 File/Search"]
Workspace --> Env["Workspace Env"]
Workspace --> Shell["Shell"]
Shell --> Sandbox["Sandbox Provider + Workspace Sandbox"]
```
依赖方向保持明确:
- `@downcity/agent` 只依赖 `@downcity/type` 中的中立资源协议。
- `@downcity/city` 依赖 Agent,并通过 Agent 内部绑定注入 Workspace、Tool 与 SessionHooks;Agent 不反向依赖 City。
- 宿主在构造每个 Shell 时选择 Sandbox Provider;City 不理解具体后端。
- Edge Adapter 使用 `@downcity/type/workspace`,不会加载 Node.js 本地实现。
## Agent 是主体
一个 Agent 可以跨项目复用,不能在配置中永久绑定单一 Workspace。
```ts
const agent = new Agent({
id: "repo-helper",
model,
instruction: "维护当前项目。",
tools: { company_search: company_search_tool },
});
city.agents.add(agent);
const frontend = await agent.sessions.create({ workspace: frontend_workspace });
const backend = await agent.sessions.create({ workspace: backend_workspace });
```
Agent 不持有 Power。City 按 Power ID 持有唯一实例,并把全部能力投影到 Agent/Workspace
execution entry。每次 Power Action 都获得当前 `PowerContext`,可以直接使用受限的 Agent、
Workspace、Session 与 Turn 句柄。Power 只有 City 级 `initialize/dispose` 生命周期;
Agent/Workspace 只定义当前调用范围。
## Workspace 是资源边界
`Workspace` 拥有:
- 稳定的 Workspace ID 与规范化项目路径。
- Rooted 项目文件能力和 File/Search 工具。
- Workspace 环境变量。
- 可选 Shell 及其 Sandbox Adapter。
Workspace 不创建 SessionStore,不理解 Agent ID,不注册 Power,不调用模型,也不创建 Storage。
构造参数中没有运行时数据根目录。
Shell 属于 Workspace,因为命令执行作用于 Workspace 资源。公开入口是:
```ts
import { Shell, Workspace } from "@downcity/city";
```
不再存在独立的 `@downcity/shell` 包。各平台 Sandbox 仍是独立安装的 package,由宿主注入 Shell。
## Session 是执行边界
`agent.sessions.create({ workspace })` 组合 Agent 与 Workspace,但不混合它们的所有权。Agent、Session 与 City 执行投影共同提供:
- Workspace Tool、Agent 自定义 Tool 与 Power 桥接 Tool 组成的最终工具集合。
- `AgentSessions` 与 Agent 领域的 `LocalSessionStore`。
- 带当前 Agent/Workspace/Session/Turn 的 PowerContext 与 execution lease。
- 日志、Power 调度任务和 Shell 绑定。
不同来源的 Tool 同名时直接失败,不会静默覆盖。同一个 Agent 可以持有绑定不同 Workspace 的多个
Session。Workspace 可以登记在 City 内,也可以由宿主在 City 外管理。
## 私有存储
City 注入本地持久化 Storage 时,运行状态集中保存在项目目录之外:
```text
~/.downcity/
└── agents/
└── /
├── sessions/
├── powers/
├── logs/
└── schedule.jsonl
```
City Storage 只是底层文件或数据库能力。Agent 解释 `agents//` 业务作用域,并在其中
创建 Session 与 Power Store。项目 File/Search 工具使用另一套 rooted FileSystem,无法读取或
修改 Session 历史、Power 状态与运行时数据。
## 环境变量与检查点
环境变量描述项目执行环境,因此属于 Workspace:
```text
/.env < WorkspaceOptions.env
```
`set_env()` 与 `patch_env()` 更新 Workspace 快照并同步 Shell。已有 Session 会在下一个 Step
检查点提交新环境,避免模型调用执行到一半时上下文发生变化。
Power registry 更新、Session model 更新和 compact 也遵循相同的检查点规则。
## 生命周期
```mermaid
sequenceDiagram
participant Host as 宿主
participant Agent
participant Workspace
participant City
Host->>Agent: new Agent(options)
Host->>Workspace: new Workspace(options)
Host->>City: city.agents.add(agent)
Host->>Agent: sessions.create({ workspace })
Agent->>Workspace: 使用当前资源
Agent->>City: 使用注入的 Storage
Host->>Agent: dispose()
City->>Workspace: 释放 City 内 Workspace
```
`city.close()` 会停止 Agent 与 Session,释放 City 内 Workspace、Storage 和 transport。City 外的
Workspace 仍由宿主负责释放。
## Package 边界
| Package | 拥有 | 不拥有 |
| --- | --- | --- |
| `@downcity/type` | 跨 package 的中立资源协议 | 具体资源实现与生命周期 |
| `@downcity/agent` | Agent、AgentSessions、Session、Store、SessionHooks、模型 Tool Loop | City 组合、Power 生命周期、项目资源实现、平台 Sandbox |
| `@downcity/city` | City、Workspace/Shell 实现、Power SDK/runtime、Hook 调度、RemoteAgent、HTTP/RPC transport、本地宿主数据 | Agent 执行内核、具体内建 Power |
| Sandbox packages | 单个平台的原生进程隔离 | Workspace、Agent、Session 或 Power 业务 |
继续阅读:[运行时架构](/zh/docs/agent/runtime-architecture)。
### Agent 运行时架构
Source: https://downcity.ai/zh/docs/agent/runtime-architecture/
Agent runtime 的核心目标是:让每次输入都按确定顺序执行,让模型和 Tool 的中间状态可以观察,让进程中断后的 Session 可以恢复。
## Session 是执行边界
每个 Session 拥有独立的:
- ID、Metadata 和模型覆盖配置。
- Message 历史与 System Snapshot。
- Prompt/Command 队列。
- Tool 审批状态。
- Composer 与 Executor。
- 实时 Mutation 流。
```ts
// Session 在创建时选择 Workspace。
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "检查并修复项目" });
const result = await turn.finished;
```
`prompt()` 返回 Turn Handle,而不是直接返回文本。`finished` 在成功和失败时都会 resolve,调用方通过 `result.success` 和 `result.error` 判断结果。
## 一次 Prompt 如何执行
```mermaid
sequenceDiagram
participant App as 调用方
participant Session
participant Queue as SessionQueue
participant Messages as SessionMessages
participant Composer
participant Executor
participant Model as LanguageModel
participant Tool
App->>Session: prompt(query)
Session->>Queue: 写入 Prompt
Queue->>Messages: 持久化 User Message
Queue->>Executor: 创建稳定 run context
Executor->>Composer: 组合 system、history、tools
Executor->>Model: 执行模型 Step
Model-->>Executor: text / reasoning / tool chunks
Executor->>Messages: 写入 Assistant 草稿与 Mutation
Executor->>Tool: 执行 Tool Call
Tool-->>Executor: Tool Result
Executor->>Model: 继续 Tool Loop
Executor->>Messages: 提交最终 Assistant Message
Executor-->>App: turn.finished
```
运行时保证:
1. User Message 在模型调用前持久化。
2. 流式 Assistant 先写可恢复草稿。
3. Text、Reasoning、Tool Call 和 Tool Result 保持模型流中的 canonical 顺序。
4. Assistant 完成后才提交到 Active 历史。
5. 中断后可以根据草稿和 Action 状态恢复。
## 状态检查点
同一 Session 只运行一个活跃执行循环。所有可能影响下一 Step 的输入进入同一有序队列:
- User Prompt。
- Workspace env 更新。
- Power registry 更新。
- Session model 更新。
- 显式 compact。
这些变化只在 Step 边界提交,不会在模型调用或 Tool 执行中途隐式改变当前上下文。正在执行的 Step 使用创建时的稳定 execution view。
模型解析优先级为:
```text
Session model > Agent model
```
Agent 和 Session 都持有宿主传入的模型实例,SDK 不根据字符串 model ID 选择或恢复模型服务。
## Composer 与 Executor
`SessionComposer` 负责模型上下文策略:组合 system blocks、转换历史、提供 Tool、生成压缩计划,并判断上下文超限错误。
`Executor` 负责一次运行:防止同一 Session 并发执行、创建 run context、调用 Composer、驱动模型与 Tool Loop,并处理 abort、重试和上下文超限恢复。
这两个对象都不关心 Session 的物理存储路径、Agent 列表或 HTTP/RPC transport。
## System Snapshot
Session 提供三种 System 语义:
- 默认:执行时使用 Agent 当前身份、instruction、Power system 和 Session context。
- `snapshot()`:把当前完整 system 固化到 Session。
- `syncshot()`:根据 Agent 当前状态重新生成并覆盖已有快照。
`system()` 返回当前实际生效的结构化 System Snapshot。Agent instruction 更新不会无条件覆盖已经固化的 Session。
## Message 与持久化
默认本地状态位于用户级 Downcity 目录中当前 Agent 的 Workspace 作用域:
```text
~/.downcity/agents//
├─ sessions//
│ ├─ instruction.md
│ ├─ meta.json
│ └─ messages/
│ ├─ active.jsonl
│ ├─ agent_message.json
│ └─ segments/*.jsonl
├─ archived-sessions/
├─ logs/
├─ schedule.jsonl
└─ sandbox/
```
- `active.jsonl` 保存上次压缩后的活动历史。
- `agent_message.json` 保存当前 Assistant 的完整流式草稿。
- `segments` 保存压缩后的不可变历史前缀。
- `instruction.md` 保存显式固化的 System Snapshot。
所有 Message 使用单调递增的 `sequence`,同一 Message 的流式更新使用递增 `revision`。JSON 文件通过临时文件、`fsync` 和 `rename` 原子覆盖;跨进程写入使用 Workspace lock 串行化。
调用方应通过 `session.messages()`、`get_info()` 和 `system()` 读取结构化状态,而不是依赖这些物理文件名。
## 实时观察
`subscribe()` 只发送订阅后发生的 Mutation:
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
process.stdout.write(mutation.delta);
}
});
```
Mutation 包括完整 Message、Assistant Part、文本/推理增量、Turn 状态和 Session 状态。断线后应先用 `messages()` 重新加载 canonical 快照,再建立新订阅。
## Power runtime
Power 属于 City,不属于 Agent 或 Workspace。City 按 Power ID 持有唯一实例,并向每个 Agent/Workspace 执行入口投影 Action、System、Hook、Resolve Point 与 HTTP 路由。
PowerRegistry 的投影变化在 Session 检查点生效。单个 Power 初始化失败只隔离自身,不阻断其他 Power。Agent 关闭时停止 Session 与 ActionSchedule;Power 的 `initialize/dispose` 生命周期由 City 收口。
ActionSchedule 是 Agent 持有的本地延迟 Power Action 调度器。事件存放在 Agent 的存储作用域中,Agent 重启时会把遗留的 running 状态恢复为 pending。它不是分布式调度系统,也不提供跨机器选主。
## City Power
每个 Agent 都会获得三个 City 自有 Power。它们既是「harness 自己知道、模型无法从文件或命令读到」那类运行时事实的出口,也承载 City 自己持有的生成能力。
```jsonc
{ "action": "sandbox.explain_path", "args": { "path": "~/.downcity/powers/messenger" } }
```
| Power | 动作组 | 回答什么 |
| --- | --- | --- |
| `context` | `env`、`sandbox`、`workspaces`、`usage` | 本轮跑在哪个 Agent、Session、Workspace、模型、时区与本地日期下;这个 Session 跑在哪个沙箱后端与实例里、挂了哪些宿主目录、某个路径为什么被拦;当前 City 中登记的全部 Workspace;user 级 token 用量 |
| `ai` | `image`、`sound` | 生成图片;转写音频与合成语音 |
| `agent` | `agent` | 当前 City 中登记的全部 Agent |
动作 id 是 `组名.动作名`,与所属 Power 名无关:`env.get` 属于 `context`,`image.*` 与 `sound.*` 同属 `ai`。不带参数调用一个 Power 会列出它的全部动作;这里不会返回凭据的值,而且每个动作都声明了 `read` 或 `write`,因此调用前就能看出哪些会消耗额度或改变状态。
可用性与 Power 同一口径:City 注册了什么,每个 Agent 就都能用,没有 per-agent 门控,也没有配置文件。
### Image 与 Sound
两者都使用你的 Federation 账号可用的模型,因此没有单独配置,也不需要设置。因为没有预设默认模型,
要么显式传 `model`,要么让模型从 `image.models` / `sound.models` 里选;`asr` 与 `tts` 在未指定时
会取第一个可用模型。
生图是两步式任务:`create` 返回 `job_id`,`result` 读取它。生图会消耗额度,所以模型必须先与你确认。
`result` 把生成的图片写入 Power 私有目录并返回本地路径,在回复里用 `` 引用即可。
入站语音消息的自动转写属于 Messenger Power:当渠道送来语音或音频附件时,由它调用 `ai` Power 的 `sound.asr`。
单个附件转写失败不影响消息送达。
## 安全模型
安全边界分为两层:
| 能力 | 强制边界 | 权限语义 |
| --- | --- | --- |
| File/Search Tools | Rooted LocalFileSystem | 只能主动访问 Workspace |
| Sandbox Shell | Workspace Sandbox | 命令在当前 Workspace 独享、持久的隔离环境中执行 |
| Host Shell | 审批网关 | 用户基于已记录的原因显式授权一次宿主命令 |
| Power | Power 自身业务授权 | Workspace 不替 Power 负责全部资源访问 |
| City Power | 按动作声明区分 | 只读事实查询与已声明 `read` 的动作;`write` 动作会消耗额度或在 Agent 目录下写文件 |
| Custom Tool | 注册它的宿主 | Agent Core 不猜测业务权限 |
显式附件可以使用宿主选择的绝对路径,因为附件已经是本次 Prompt 的明确输入。这不会扩大模型 File/Search Tools 主动遍历文件系统的范围。
## 跨平台策略
Node.js 已经统一文件、路径、网络、JSON、Timer、AbortController 和普通子进程能力,所以 Agent、Session、Store 与 Workspace 不写操作系统分支。
隔离实现差异统一收敛在中立的 Sandbox Provider 协议内。每个 Shell 显式接收 Provider,并持有绑定 Workspace 时创建的独立、持久 Sandbox。City 只管理 Workspace 资源,不理解其 Shell 使用本地容器、microVM、远程主机还是其他后端。默认实现使用 microsandbox microVM。
## 生命周期
```mermaid
flowchart LR
Agent["new Agent"] --> Register["City 注册 Agent"]
Power["City 注册 Power"] --> Initialize["Power initialize"]
Register --> Running["执行 Session"]
Initialize --> Running
Running --> Close["city.close()"]
Close --> Sessions["停止 Agent Sessions"]
Sessions --> Dispose["Power dispose"]
Dispose --> Workspace["释放 Workspace / Shell"]
```
Session 执行前会等待 Agent 的 City 执行绑定就绪。`agent.dispose()` 不停止 City Power 实例;`city.close()` 统一关闭 Transport、Agent、Power、Workspace 与 Shell,并保证活跃 Hook/Action lease 先收口。
继续阅读:[Agent 生命周期](/zh/docs/agent/lifecycle)。
### Agent 生命周期
Source: https://downcity.ai/zh/docs/agent/lifecycle/
`new Agent(options)` 只创建 Agent 身份、模型、指令、自定义 Tool、Session 集合与 ActionSchedule。Power 不属于 Agent;City 注册 Agent 后,把自己持有的 Power 能力投影到 Agent 的执行入口。
同一个 Agent ID 在同一物理 Workspace 中只能运行一个活动 runtime。ActionSchedule 是本地调度器,不负责协调重复启动的 Agent 进程。
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const shell = new Shell({ sandbox_provider: new MicrosandboxProvider() });
const agent = new Agent({
id: "my-agent",
model,
});
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell,
});
const city = new City({ agents: [agent], workspaces: [workspace] });
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "开始" });
await turn.finished;
await city.close();
```
Session 会在执行前自动等待 Agent 的 City 执行绑定就绪。`agent.dispose()` 只释放 Agent 自身的 Session、ActionSchedule 与执行绑定;`city.close()` 会先停止 Agent Session,随后停止 City 持有的唯一 Power 实例,并关闭 Workspace、Shell 与 Transport。
继续阅读:[Agent SDK](/zh/agent-sdk-docs)。
### Power
Source: https://downcity.ai/zh/docs/power/overview/
**Power(Power)** 是 Downcity 唯一的扩展产品单元。它由 City 提供和维护,可以向 Agent/Session 作用域投影运行能力,也可以带上宿主管理逻辑、Sidebar + Mainview 业务工作区和 Config。
```text
Power
├── main 可选:City 运行实例、生命周期、管理逻辑与 actions
└── renderer 可选:Sidebar + Mainview 和/或 Config
```
## Power 能做什么
- 向 Agent 暴露可调用的工具
- 接入 City Power 生命周期(initialize、dispose)
- 管理自己的状态、连接和后台任务
- 提供 Sidebar + Mainview 工作区,并在 Catalog 详情的独立 Config 中管理配置
- 在 City 进程边界内运行,不作为独立服务
一个 City 中每个 Power ID 只有一个实例和一份配置。配置由宿主持久化,并在每次执行时作为
`PowerContext.config` 快照提供给 Power;Agent 不持有 Power、配置或生命周期。多账号由
Power 自己的配置结构表达,不存在通用命名配置层。
## 内置 Power
Downcity 内置了多个 Power,覆盖常见的 Agent 需求:
- **Chat** — 连接聊天渠道(Telegram、飞书、QQ 等)
- **Task** — 调度和运行后台任务
- **Memory** — 持久化会话历史和上下文
- **Shell** — 在沙箱环境中执行本地命令
- **Schedule** — 类 Cron 的作业调度
- **Web** — 浏览网页并提取内容
- **Skill** — 组合可复用的工具链
## 编写 Power
SDK 中可以直接向 City 提供 Power 实例,不需要经过安装协议:
```ts
const city = new City({ powers: [github_power], workspaces: [workspace] });
city.agents.add(agent);
```
需要被 CLI 或 Desktop 安装和管理时,再通过 `power.json` 声明统一 `main` 与可选 `renderer` 入口。
## 继续阅读
- [Agent Powers Docs](/zh/powers-docs) — 内置 Power 手册和示例
- [Agent SDK](/zh/agent-sdk-docs) — 如何在代码中注册和使用 Power
- [自定义 Power](/zh/docs/power/custom) — 可安装 Power 的统一 main 协议
### Messenger Power
Source: https://downcity.ai/zh/docs/power/messenger/
Messenger Power 是 City 持有的渠道运行时。它不属于某个 Agent,也不通过 `context.agent.chat` 暴露长期状态。
## Desktop 使用
打开 Messengers 后,左侧 Sidebar 会列出全部 Bot Account。点击 Sidebar 标题栏中的加号,从下拉菜单选择 Telegram、飞书/Lark 或 QQ 后创建对应的 Bot Account。可以添加多个同平台 Bot Account。选择飞书/Lark 时默认使用扫码创建,不需要手动填写 App ID 与 App Secret。
点击一个 Account 可以管理:
- 启用状态、平台凭据与连接测试
- 新 Conversation 默认绑定的 Agent
- 外部身份的 Access 申请与授权
- 每个 Conversation 的 Agent、Channel Session 与暂停状态
- 达到自动重试上限的 Inbox/Outbox 任务
- 不包含消息正文和密钥的 Activity
在 Desktop 保存 Account 后立即应用,只重启该 Account 的 Connector,不需要重启 Desktop 或 City,其他 Bot 连接不受影响。密钥可以直接在 Settings 中写入;已保存的密钥不会回显,编辑时留空表示保留原值。
:::callout
Telegram 等平台在部分网络环境下需要代理才能访问。只要在系统或 Desktop 中开启代理,Channel 会自动使用,无需额外配置。
:::
### 网络与连接
Telegram、飞书与 QQ 的请求都经过 Power 统一出站层,行为一致:
- 每个请求都有整体超时,不会无限挂起。连接失败时 Account 会进入 `错误` 并直接展示原因。
- 代理优先级:Desktop 网络代理设置 → 操作系统系统代理 → `Global / Env` 中的 `HTTPS_PROXY` 等变量。macOS 开启系统代理后 Power 会自动继承,系统例外列表会转换为 `NO_PROXY`。
- 代理变化(包括开系统代理 / 连 VPN)后,Desktop 会在 30 秒内自动重新解析并重连已启用的 Channel,无需重启。
- 只支持 `http://` 或 `https://` 代理地址;`socks5://` 会直接报错并提示改用 http 地址。`NO_PROXY` 支持主机名、`.example.com`、`*.example.com` 与 IPv4 CIDR。
- 连接意外失败后会自动按退避重连(最长 5 分钟一次),成功后退避重置。Account `未连接` 时 Outbox 只延后发送,不消耗重试次数。
如果 Account 一直停在 `连接中`,先点一次“测试连接”;仍失败时,界面上的错误文本会给出具体原因(例如代理不可用或目标不可达)。
### Agent 附件展示
Agent 使用 `reports/daily.md` 输出附件,标签内填写当前 Workspace 的相对文件路径。Desktop 聊天会按正文顺序展示附件:文档显示文件入口,`photo` 显示图片,`audio`、`voice` 和 `video` 显示播放控件。本地媒体通过 Desktop 受控文件协议加载,点击文件入口沿用聊天文件导航。
代码块、行内代码和转义的标签保留为示例文本。流式输出中的标签尚未闭合时保持可读,闭合后转换为附件。标签内不要写 Markdown 链接或 `http://文件名`;这类内容会显示格式错误并保留原始标签。媒体无法预览时保留文件入口。附件使用统一卡片展示文件类型与名称,长文件名悬停可查看完整路径;格式错误的原始标签收在“查看详情”中。
## Account 配置
Account 定义与密钥以 City Power Config 为唯一事实源。示例:
```json
{
"accounts": [
{
"account_id": "support-telegram",
"name": "Support Bot",
"provider": "telegram",
"enabled": true,
"agent_id": "support",
"bot_token": ""
}
]
}
```
飞书/Lark 使用 `app_id`、`app_secret` 和可选 `domain`;QQ 使用 `app_id`、`app_secret` 和可选 `sandbox`。
在 Desktop 中添加飞书/Lark Channel 时默认走扫码创建:点“扫码创建”,用飞书扫描二维码并在手机上确认,App ID 与 App Secret 会自动写入并立即启动,不需要到开放平台手动复制。确认页会预填应用名称与描述,并勾选本渠道需要的权限点和 `im.message.receive_v1` 事件。
扫码基于 OAuth 2.0 Device Authorization Grant(RFC 8628),只允许创建新应用,不会覆盖已有应用的配置。二维码有效期以平台返回为准(当前约 1 小时),过期后可以重新生成。无法扫码时(例如企业未开启开发者权限,或需要复用已有应用),在同一页面切换到“手动填写凭据”即可。
## 消息与 Session
允许访问的外部消息先写入 Inbox,再由 Conversation 路由到独立的 Channel Session(`category = "chat"`,`type = "channel"`)。每个 Bot Account 下的聊天及 Thread 各有一条 Session,同一聊天后续消息复用它。不同聊天的历史彼此独立,也与 Agent 主对话分开。Session Messages 是完整对话的唯一事实源。
IM 只绑定 Agent,不需要选择 Workspace。Desktop 中 Channel Session 与 Agent 主对话默认共享该 Agent 的主 Workspace(`agents//main`),具备相同的 Workspace 工具、Power、Agent 自定义工具和 Session 管理工具。对话历史分别保存,文件与 Shell 使用当前 Workspace。可通过 Session 管理工具创建项目 Work Session。SDK 宿主通过 Agent 的 `workspace` 提供默认主上下文。账号未配置 Agent 时,仅在 City 中 Agent 唯一时自动选用。
切换 Conversation 的 Agent 或重置对话会创建新的 Channel Session,原 Session 保留。升级前的 IM Work Session 也保留,升级后聊天从新建的 Channel Session 继续。附件接收保存到 Messenger 自有目录;发送时可使用该目录及 Session 当前项目目录。
可靠链路为:
```text
Platform → Access → Inbox → Conversation → Channel Session
→ Outbox → Platform
```
Inbox 使用平台消息 ID 去重,并以稳定 `request_id` 调用 Session。进程在 User Message 落盘后退出,恢复时不会追加第二条用户消息。Agent 结果以稳定 Delivery ID 写入 Outbox,避免恢复时重复建单。
Outbox 提供持久化、lease、有限重试和人工恢复。平台本身不支持幂等发送时,对外语义是 at-least-once:平台已接收但本地尚未记录确认的瞬间退出,恢复后可能再次发送。
## Agent Actions
Agent 只看到自己拥有的 Conversation,并使用严格输入的 Action:
- `context`:读取当前 Chat Session 路由
- `list`:列出当前 Agent 的 Conversation
- `history`:读取 Agent Session 的 canonical messages
- `send`:将文本写入可靠 Outbox
- `react`:为 Telegram 消息写入可靠 Reaction 操作
## SDK 装配
```ts
import { MessengerPower } from "@downcity/powers/messenger";
const city = new City({
powers: [new MessengerPower()],
workspaces: [workspace],
power_host,
});
```
Messenger Power 需要 City 宿主提供 Power Config、生命周期 Storage 和 Agent Session 能力。Bot Connector 在 `initialize()` 中主动恢复,在 `dispose()` 中统一关闭。
## 飞书依赖
启用飞书/Lark Account 的宿主需要安装:
```bash
npm install @larksuiteoapi/node-sdk@^1.73.3
```
扫码创建依赖 SDK 的 `registerApp`,需要 `^1.67.0` 及以上版本;低于该版本时扫码入口会提示升级,手动填写凭据仍然可用。
## 相关文档
- [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle)
- [Built-ins Overview](/zh/powers-docs/builtins/overview)
### Task Power
Source: https://downcity.ai/zh/docs/power/task/
{/* 用户文档:区分 Todo 与 Schedule 外壳,执行仍由 City 中的 TaskPower 负责。 */}
进入 Desktop 的任务页面,先在左侧顶部选择**列表、看板、日历、甘特图**中的一种视图,再在下方任务列表中点选任务。主区域展示所选视图;点选任务后,主区域换成该任务的正文与执行过程,顶栏左侧出现返回按钮,返回即回到原视图。任务正文不再占用右侧 BayBar 标签。
视图的搜索、筛选与排序在主区域顶栏,只作用于当前视图的集合;侧栏列表先按**待办 / 日常 / 未标注**分段,每段内部再分未完成与已完成,段内把需要你处理的任务排在前面,并在行尾标出待验收、可重试或整理失败。未标注是还没有选择类型的历史任务;在它的正文里选择待办或日常,它就会移到对应分段。找不到任务时用主区域的搜索,而不是在侧栏里翻。看板与日历/甘特图不显示筛选入口,因为它们用不到。
新建任务:在底部输入想做的事。系统生成并校验后直接保存、生效,输入框上方显示安排摘要,无需再次确认。列表使用有表头的紧凑表格,按任务名称、类型、状态、执行者、时间、操作对齐;窄窗口可横向滚动,列不会被隐藏。仅名称或菜单中的“打开正文”进入任务正文。类型显示说明,状态打开适用操作菜单,执行者和时间在字段浮层中修改;圆点不能切换时会说明原因,不跳转正文。筛选不影响底部输入的生成类型。无需先选择任务类型。
左侧圆点支持个人一次性任务的完成与未完成切换;Agent 交付需先查看结果再验收,循环启停不等于工作完成。右侧 `···` 菜单按当前状态提供在底部输入区调整要求、开始或重试执行、查看详情、重新打开、循环启停与删除。运行中限制编辑、删除和重复执行;删除会要求确认。字段保存失败保留修改,成功后直接应用执行目标或安排变更。
默认排序将未完成任务放在前面,按最近执行或计划时间排列,无时间项放后面,已完成或取消任务置后。点击名称、类型、状态、执行者、时间表头切换升序/降序,箭头表示当前方向;时间按实际时刻比较,循环使用下次可计算触发时间,暂停或无法预测的循环在时间排序中置后。相同值以创建时间和任务 ID 稳定排序。筛选菜单提供“恢复默认排序”。
## 界面层次
Task 以完整 Card Block 组织空间:列表分为“未完成”和“已完成”两个独立内容块,各有数量与表头,组内沿用当前排序;日历、甘特图分别为完整内容块,看板每个任务卡为内容块,详情信息、进度与结果分别独立。只有完整 Block 使用统一背景、圆角与内边距;工具栏、列标题、Sidebar 分区标题和输入外层保持透明。ChatInput 自身是 Block。无需装饰描边或阴影,保留必要 hover、选中和聚焦反馈;日历及甘特图保留必要的弱时间刻度,月视图使用明确日期网格。所有浮层均使用不透明主题底色,避免下方内容透出。
## 展示方式
任务侧栏顶部可切换**列表、看板、日历、甘特图**,下方按**待办 / 日常 / 未标注**列出任务;主区域展示所选视图或选中任务的正文。底部输入框在视图下始终可用。选另一个视图会先退出正文,回到纯视图。
视图的搜索、筛选与排序位于主区域顶栏,作用于当前视图的集合。
看板按待开始、进行中、待处理和已完成展示;循环任务同时显示启停状态,不把启用规则当成执行中。卡片名称打开详情,时间字段原位编辑,右侧菜单与列表共用快捷操作;循环卡单独标明启停,本次完成不结束循环。当前不提供拖拽跨列修改。
日历支持日、周、月切换。日/周视图按小时排列事件,同一天的时间段按时长显示,重叠事件并排;日期型截止和跨天范围放在全天区,今天显示当前时间线。时间点仅以紧凑块呈现,不因此获得真实时长。
日历填满主内容区,工具栏与日期表头固定,时间网格独立滚动;跨天范围在全天区以连续横条展示。点击日程先打开摘要预览,可查看执行者、时间和行为;点击“修改时间”才展开编辑,明确点击“打开详情”才进入 BayBar。一次性计划支持拖动改期并保留时长,已有同日时间段可拖动底边调整结束时间,也可在预览中编辑起止时间。循环和截止事件不提供这些拖拽编辑。时间变更通过校验后直接保存并同步调度。点击空白半小时时间格把该时间带入底部 ChatInput,不弹创建表单。网格按本机时区显示,循环按任务时区展开;高级 Cron 未能展开时明确列出。
甘特图展示两周范围,可前后翻页;任务名称列和日期表头固定,今天以竖线标记,范围外任务可点击方向提示定位。计划时间段显示横条,时间点显示独立菱形标记;循环任务展示下次可计算的触发时间。无时间、暂停循环或无法预测的高级规则列在下方,可打开详情处理。甘特图当前用于查看,不提供拖动编辑或依赖连线。
## 待办
待办用于推进一件要完成的事。进入任务页面即可在页面底部的 ChatInput 描述想做的事,Agent 会理解目标、执行者和时间,生成安排预览。无需先选择类型或打开新建表单。
Todo 是一次性工作,可没有时间、指定一个时间点,或设置计划起止时间段。时间点可以按用户要求提醒或执行一次;只有计划时间段时不会自动持续运行。截止日期不自动启动 Agent。自己的待办可以从列表完成并撤销;Agent 返回结果后可以“完成待办”或提出修改要求。
如果保存成功但开始失败,窗口会提示“待办已保存,尚未开始”。再次开始使用原对象,不重新创建待办。
## 定时安排
Schedule 只用于循环工作,基于 Cron 规则与时区。在同一个 ChatInput 中输入“每周一让研究员整理动态”会生成 Schedule;“明天九点提醒我”则生成一次性 Todo。生成成功后直接应用时间与执行者设置。
循环任务详情显示时间摘要和启停开关,详细设置收在“更多”。暂停不停止正在执行的工作;本次成功不会结束循环。
个人提醒到时产生待处理提醒,不运行 Agent。时间段由 planned_at 与 planned_end_at 保存,结束必须晚于开始,不代表重复或持续执行。自动触发需要 City 保持运行;错过的一次性 Todo 需要用户处理,离线周期不会集中补跑。
## 创建接口
保留 `task.create()` 与 `task.generate()` 两个入口。Agent 已掌握标题、正文和时间等结构化字段时优先使用 `create`,直接校验保存,跳过内部模型。Task 页面输入自然语言时使用 `generate`,Agent 也可按需调用。页面与 Agent 共用同一个生成动作。
`generate` 传入 `text` 新建,传入 `task_id` 与补充内容调整原任务;调用等待整理完成并返回完整任务。新提交重试复用 `submission_id`。成功后直接保存并生效,无需确认;仅记录任务不会启动,明确要求立即执行才开始,定时和循环工作按规则触发。`create` 的手动任务通过 `run` 启动。模型失败保留任务身份和原因,可在原任务上重试。时间校验统一处理格式、时区和过去时间;相对日期按本次生成时间解析。
## 直接输入与保存
操作按“输入 → 生成并保存生效 → 查看安排或结果”组织。输入框上方展示内容、执行者与时间;理解不对时继续输入调整同一任务。仅记录、立即执行和定时执行按用户要求区分,不额外显示确认按钮。启动或调度失败会显示具体原因,已保存内容仍保留。
任务页只有一个常驻 ChatInput,列表独立滚动。调整时显示任务名称,“新任务”切回新建并保留原任务和草稿。要求按需展开,发送栏保持可见。输入 `@` 选择 Agent,Enter 提交描述,Shift+Enter 换行。生成失败保留任务供重试。
系统根据完整输入区分待办与循环安排,当前列表筛选不限制生成类型。截止日期不自动触发执行。任务保持同一身份,标题可修改和重复。
## 任务正文与历史
点击侧栏任务行或视图中的任务名称,主区域换成该任务的正文与执行过程;顶栏左侧的返回按钮回到原视图。生成失败时提供调整与重试入口;执行中或已有结果时默认收起任务要求,优先呈现进度和结果。执行者、类型、状态与看板保持一致;属性收在“更多”,历史按需展开。
列表不再按内部状态或类型拆成多个区块。旧任务也保留在同一列表中,底层身份和历史不变。查看通知不等于完成或验收。
## 结果、修改与失败
引用在输入框、队列和已发送消息中使用统一紧凑 Block。任务交付显示任务名称与结果摘要;聊天消息、文件内容、代码选区使用各自的图标和来源信息。点击引用可展开完整正文,编辑中的删除按钮位于右侧,只读消息不显示删除按钮。样式无阴影或装饰边框,展开与摘要不改变实际发送的完整上下文。
交付结果提供两个操作:“复制任务引用”同时复制结构化 HTML 与可读纯文本;粘贴到 ChatInput 恢复任务引用 Block,粘贴到外部应用可读取任务来源与完整正文。引用 Block 自身也有“复制引用”,编辑器选中引用后可用系统复制;纯文本粘贴或代码块内粘贴按普通文字处理;“继续任务”使用任务的 Agent 与 Workspace 打开工作区新对话,并将当前交付作为结构化引用带入 ChatInput,无需预先打开会话,也不自动发送。对应新对话已有草稿会保留并追加引用;新 Session 按应用原有新对话流程在发送时建立。执行者或工作区不可用时在原处提示修正,不任意换目标。
执行结果和历史保留在原对象。一次性 Agent 工作需要人工接受;周期结果遵循逐次验收设置。
点击“需要修改”填写反馈,再点击“提交修改”,一次完成保存反馈和启动后续执行。若保存成功但启动失败,反馈仍保留,可从“开始修改”重试,无需再次提交反馈。旧结果和反馈保留;循环任务的本次反馈不自动改写固定要求。
执行失败可以重试,AI 整理失败则重试整理。内容已保存但自动安排同步失败时,显示“安排尚未生效”,可重试同步,不重新调用模型。
City 重启发现中断工作时会保留历史并提示处理,不自动重放执行。运行中的任务暂时需要等待结束后再编辑定义或删除;工具审批仍在原执行会话处理。
从聊天创建时,结果交付位置由来源会话捕获;更换执行者不会移动交付目标。任务需要发送外部消息时,应明确要求执行 Agent 使用 Messenger。
继续阅读:[Messenger Power](/zh/docs/power/messenger)、[Agent Powers Docs](/zh/powers-docs)。
### Memory Power
Source: https://downcity.ai/zh/docs/power/memory/
一份 Downcity 平台数据根对应一个本地 User,独立于 Federation 登录账号。City 管理基础资料,MemoryPower 管理用户记忆和 Agent 记忆。
## 每轮上下文
首次组装或 syncshot → 读取两级 Profile → 与 Instruction 一起冻结完整 system → Agent 处理消息并按需调用工具。
User Profile 跨本地 Agent、跨 Session 生效;Agent Profile 仅对指定 Agent 生效。Identity 与 Instruction 合并为 Agent system,Agent Profile 紧随其后;User Profile 作为另一条 system 放在 Session 信息之前,不混入 User 原文,参与完整 system 快照。文件更新后,新 Session 使用最新版;已有 Session 需 syncshot 刷新,继续对话不会自动改变。Profile 是历史约定,不是本轮任务,不能覆盖当前请求和权限。不使用 Jev 或周期 Session 提取。
## 保存
```json
{"target":"user","mode":"profile","content":"主回答前提醒我使用英语,我写英语时简要纠正表达。"}
```
remember 的 target 为 user、agent、current_workspace;mode 为 profile 或 knowledge,默认 knowledge。Profile 支持 User/Agent;Workspace 仅支持 knowledge。User 写入无需登录或身份回调。Agent 自己判断何时调用工具,只有成功后才能确认已保存。
## 文档与维护
`user/user.json` 保存本地用户资料;`user/PROFILE.md` 保存全局持续约定;`agents//PROFILE.md` 保存 Agent 专属约定。长期知识由 search/read 按需读取,User 知识保存一次供本地 Agent 共享。
两级 Profile 添加均无篇幅上限,每天分别检查一次,超过 2,000 Unicode 字符时调用 profile_model_id 精简。编辑校验唯一匹配、不重叠和版本,并保留备份。没有优化模型仍可保存和加载。
全局资料和 Profile 的宿主管理 API 已提供,专属 Desktop 界面待接入。切换服务账号不会切换用户记忆;外部渠道访问权限由宿主控制。
- [完整 Memory Power 文档](/zh/powers-docs/builtins/memory)
### MCP Server
Source: https://downcity.ai/zh/docs/power/mcp/
MCP 由内建 Skill Power 统一管理。Sidebar 按 Skills 与 MCP 列出具体资源,选择 Server 后查看状态、工具与错误。Server 定义在 Skill Config 中管理。已有 Server 定义与 OAuth 凭据仍保存在全局 MCP 存储目录,由所有 Agent 和 Workspace 共享。
在 **Agent Profile → Skills** 中为 Agent 启用 Server。访问必须经过明确分配,分配关系以 Server ID 的 JSON 数组保存在 `~/.downcity/agents//mcp-servers.json`。文件不存在时没有访问权限;仅全局启用 Server 不会授予权限。移除分配不会删除共享定义和凭据。宿主动作 `skill.mcp.assign` 与 `skill.mcp.unassign` 接受 `{ agent_id, server_id }`,不暴露给模型。
## 配置 Server
每个 Server 默认使用一个 `~/.downcity/powers/mcp/servers/.toml` 文件,文件名就是 Server ID。如果宿主使用其他 MCP 配置路径,`servers` 位于该配置文件的同级目录:`dirname(config.path)/servers`。
已安装的本地 stdio Server 示例:
```toml
title = "Filesystem"
enabled = true
command = "mcp-server-filesystem"
args = ["/absolute/allowed/directory"]
cwd = "/absolute/working/directory"
env = { LOG_LEVEL = "info" }
request_timeout_ms = 60000
max_result_chars = 100000
```
命令和参数需要替换为实际 Server 要求的值。Streamable HTTP Server 示例:
```toml
title = "Remote MCP"
url = "https://mcp.example.com/mcp"
headers = { "X-Client" = "downcity" }
```
`command` 和 `url` 必须且只能填写一个非空值。本地程序使用可选的 `args`、`cwd`、`env`,HTTP 端点使用可选的 `headers`。配置没有 `source`、`install`、`transport` 分层。`title` 可省略;`enabled` 默认 `true`;可选的 `request_timeout_ms` 和 `max_result_chars` 默认分别为 `60000` 毫秒和 `100000` 字符。需要项目目录的 Server 应明确填写 `cwd`,共享连接不会继承调用者的 Workspace。参数、工作目录、URL 和请求头中的 `${NAME}` 从宿主环境读取变量。
Desktop 编辑器直接读写每个 Server 的真实 TOML 文件,每个 Server 都需要明确点击保存或删除。文件修改会以读取时的原文(`expected_content`)检查冲突;若文件已被其他编辑修改,会拒绝本次操作,防止过期内容覆盖新内容。此时应重新读取文件后再保存。
初始化、定义读取和单文件保存都只扫描、校验定义,不启动或连接 Server。普通手动文件编辑可通过 Config 重新读取发现,不执行 Server。显式测试、Mainview 刷新连接或首次 `tools`/`call` 请求才会连接 Server。资源详情中的刷新只重新连接当前选中的 Server;Config 的全局刷新会重新连接所有已启用的 Server。stdio 与 HTTP 连接的工具发现都会读取工具列表的全部分页。校验错误会指向对应文件,其他合法 Server 仍然可用。定义变更、停用或删除时释放对应连接,City 关闭时释放全部连接。
加载、编辑定义不会安装程序。已知的 `npx`、`bunx`、`uvx` 等隐式安装运行器,以及包管理器的安装执行入口会被拒绝;应先通过已有审批流程安装程序,再配置已安装的可执行程序。旧 `config.servers` 集合不再读取,也不会自动迁移,需要明确创建独立 Server 文件。
## HTTP OAuth 授权
支持 MCP 标准 OAuth 的 HTTPS Server 收到 401 后会自动进入授权:Downcity 使用 MCP SDK 发现资源与授权服务器元数据,动态注册客户端,生成 PKCE,并通过系统浏览器打开登录页。完成登录后,授权码返回本机 `127.0.0.1` 回调,当前连接自动恢复。只有测试、刷新连接、工具发现/调用或点击重新授权才可能打开浏览器;读取状态和保存配置不会打开浏览器。
```toml
title = "OAuth MCP"
url = "https://mcp.example.com/mcp"
auth = "oauth"
# 授权服务器不提供动态客户端注册时填写预注册的公共客户端 ID
# client_id = "your-client-id"
# client_name = "Downcity"
```
OAuth 配置与 `url` 一样位于 TOML 顶层。`auth` 可省略,标准 401 会自动触发发现。预注册客户端需要支持本地回环地址与动态端口。授权元数据、issuer、注册及 token 端点必须使用 HTTPS。静态 `headers` 仍然适用于 API key 等鉴权方式;OAuth token 可用时使用 Bearer token。
MCP Server 详情展示 `none`、`authorizing`、`authorized`、`expired` 授权状态,提供“重新授权”和“取消授权”。不同 Server 的连接和授权独立;同一 Server 的请求串行执行,避免重复弹出授权窗口。授权最长等待五分钟,并受调用取消和 City 关闭控制。工具请求仍使用 `request_timeout_ms`,因此调用期间需要重新登录时,剩余调用时限可能更短。
凭据保存在 `dirname(config.path)/auth/`,按 Server ID、完整 endpoint 与 issuer 生成隔离文件名,文件权限为 `0600`,目录为 `0700`。保存的是明文 client/token 与过期时间,不保存授权码、state 或 PKCE verifier,也不向 Agent 状态或日志返回 token。有效 refresh token 由 SDK 刷新,刷新失败可重新登录。HTTP session ID、协议协商和 SSE 由 SDK 管理;明确收到 404 时最多重建一次会话并重试,网络结果不明的调用不会自动重放。
实际 Server 必须提供符合协议的发现与授权接口。Twinkle Cloud 的实际协议兼容性和首登、刷新、重启免登录尚未联调验证。
## Agent 如何使用
Agent 使用 Skill Power 的 `servers`、`tools`、`call` 三个动作。Server 不会变成独立 Power,外部工具按需发现。Skill 的 `list` 与 `lookup` 继续用于技能发现和读取。
```ts
skill({ action: "servers" });
skill({ action: "tools", args: { server_id: "filesystem" } });
skill({ action: "call", args: {
server_id: "filesystem",
tool_name: "read_file",
arguments: { path: "/allowed/example.txt" },
} });
```
先通过 `servers` 查看分配给当前 Agent 的 Server 及其状态,包括已停用或定义无效的 Server,再用 `tools` 读取原始工具名称和输入 JSON Schema,最后调用 `call`。示例中的工具名和路径需要替换为实际 Server 提供且允许使用的值。调用前会在 MCP 模块内校验参数;未分配、停用或定义无效的 Server 不能调用。`tools` 与 `call` 都会检查 Agent 分配关系。
这是结构化的发现和调用接口,没有独立 Shell 命令行程序。Server 文件通过 Desktop 管理,Agent 通过 `skill` 的 `servers`、`tools`、`call` 使用 MCP。
## 审批与边界
`call` 的访问元数据为 write,且 `approval: false`;调用已启用且分配给当前 Agent 的 Server 时不追加 MCP 审批。MCP 不提供安装、卸载工具,程序安装和卸载通过已有审批执行流程完成。Server 描述和返回值作为外部数据处理,不作为 Agent 指令。请求支持取消和配置的调用超时,过长结果返回截断标记。
stdio Server 会执行本地程序,应由用户明确配置可信 Server。凭据应在所属环境配置中维护,避免在聊天里提交明文。当前不支持 MCP 交互式输入往返。
Server 文件变化后,三个 Agent 动作保持不变,无需重建或同步 Server Power。
### Shell
Source: https://downcity.ai/zh/docs/power/shell/
Shell 是 Workspace 能力,不是 Power。它持有 Sandbox Provider、当前 Workspace 的隔离 Sandbox 与可复用的 Shell Session。
## 功能
- 默认在 Workspace Sandbox 中执行命令
- 在不同 Chat Session 之间保留已安装工具和 HOME 状态
- 捕获 stdout、stderr 和退出码
- 支持超时和取消
## 安全
Shell 只有 `sandbox` 和 `host` 两个执行目标。默认使用 Sandbox,且不能看到任意宿主路径;host 执行必须提供明确原因并经过宿主审批。Sandbox 失败时不会回退到宿主执行。
## 使用场景
- 运行构建脚本
- 文件系统操作
- 查询系统状态
- 执行项目特定工具
继续阅读:
- [Messenger Power](/zh/docs/power/messenger)
- [Task Power](/zh/docs/power/task)
- [Agent Powers Docs](/zh/powers-docs)
### 自定义 Power
Source: https://downcity.ai/zh/docs/power/custom/
一个目录只定义一个 Power。Power ID 同时是安装目录名、City catalog 键和运行时身份,不需要再声明 Extension。
## 两个可选入口
```text
Power
├── main 提供 City 执行实例、生命周期、管理逻辑与 actions
└── renderer 声明 Sidebar + Mainview 业务工作区和/或 Config
```
两个入口都可选,但至少提供一个。一个 Power 可以只提供 City 运行能力、只提供 Sidebar + Mainview 业务工作区、只提供 Config,或同时提供完整体验。
推荐的开发目录:
```text
github-power/
├── power.json
├── package.json
├── README.md
├── assets/icon.svg
├── src/
│ ├── power.ts
│ ├── main.ts
│ └── renderer.tsx
└── dist/
├── main.js
└── renderer.js
```
两个入口都使用 ESM。`main.js` 必须自包含;`renderer.js` 是单文件浏览器 bundle,默认导出一个 Renderer 定义,其中可以声明成对的 `sidebar + mainview` 与独立的 `config`。Downcity 不安装依赖或运行构建脚本。
## 直接用于 SDK
SDK 用户可以直接构造 `CityPowerRegistration`,不经过 `power.json`:
```ts
const registration = {
id: "github",
title: "GitHub",
description: "GitHub integration",
readme: "/absolute/path/README.md",
has_config: false,
has_sidebar: false,
has_mainview: false,
power: new GithubPower(),
};
const city = new City({
powers: [registration],
workspaces: [workspace],
});
city.agents.add(agent);
```
只有需要被 CLI 或 Desktop 安装、发现和管理时,才需要下面的入口协议。
## Power 定义
```json
{
"schema_version": 1,
"id": "github",
"version": "1.0.0",
"title": "GitHub",
"description": "GitHub 集成",
"readme": "./README.md",
"icon": "./assets/icon.svg",
"main": "./dist/main.js",
"renderer": {
"entry": "./dist/renderer.js",
"sidebar": true,
"mainview": true,
"config": true
}
}
```
`renderer.sidebar` 与 `renderer.mainview` 必须同时为 `true` 或 `false`,`renderer.config` 可以独立声明;默认导出的实际组件必须与这三个静态能力完全一致。`package.json` 必须声明 `"type": "module"`。`readme` 必须声明 Power 根目录内的 `.md` 用户文档,Desktop 会在 Power Overview 中安全渲染它;文件名和子目录可自行组织,应写清用途、配置方式和必要凭据。图标可以是 HTTP(S) 地址或 Power 根目录内的相对文件,并会用于 Power 导航与 Overview。
## 统一 Power 入口
`main` 文件默认导出唯一 Power 实例;不再存在独立 `agent` 入口或第二个 main 生命周期。
Power 在 `initialize()` 中通过 `power.action()` 注册 Sidebar/Mainview 动作,通过
`power.config_action()` 注册 Config 动作:
```ts
import { Power, type PowerLifecycleContext } from "@downcity/city/power";
class GithubPower extends Power {
readonly name = "github";
readonly title = "GitHub";
readonly description = "GitHub 集成";
initialize({ power, notifications }: PowerLifecycleContext) {
power.config_action({
id: "config.read",
run: async (_input, context) => {
const config = context.config.get();
return { api_url: config.api_url, token_configured: Boolean(config.token) };
},
});
power.config_action({
id: "config.save",
run: async (input, context) => {
const current = context.config.get();
const config = validate_and_restore_secret(input, current);
await context.config.set(config);
return { saved: true };
},
});
void notifications.publish({
topic_key: "sync:repository",
title: "仓库同步完成",
route: { repository: "downcity", view: "activity" },
});
}
}
export default new GithubPower();
```
一个 Power ID 在 City 中只对应一个实例、一套生命周期和一份配置。Config action 调用时由 City
注入唯一 Config store,执行实例从 `PowerContext.config` 读取该配置的当前快照。
`notifications.publish()` 与 `notifications.dismiss()` 始终绑定当前 Power。宿主会把 `topic_key` 放入 Power 命名空间,并把 `route` 解释为当前 Power 工作区路由,因此 Power 不能代表其他 Power 或跳转到任意 Desktop 页面。相同 `topic_key` 只保留最新一条未读通知。
`PowerLifecycleContext.system` 只提供明确授权的 City 宿主能力。需要把后台结果写回既有 Session 时,使用 `append_agent_session_message({ agent_id, workspace_id, session_id, category, text })`;四个身份字段必须来自已持久化的明确目标,不能扫描或猜测 Session。
需要实现类似 Skill 管理器的功能主页时,可以通过 `system.list_workspaces()` 读取宿主已经登记的 Workspace 摘要。Power 是受信任本地 Node.js 代码;它负责验证 action 输入,并自行拥有文件、网络和长期资源的业务行为。
## Renderer 入口
Renderer 入口在同一个 bundle 中声明 UI 插槽。`sidebar` 与 `mainview` 共同构成 Power 工作区,必须成对出现,并共享宿主持有的 JSON route;宿主会为它动态增加一级导航入口。`config` 与功能工作区无关,只在 Power Catalog 详情中渲染。可选的 `agent_config` 向 Agent Profile 贡献一个页签。这些界面使用宿主 UI Components:
```tsx
import { useEffect, useState } from "react";
import { define_power_renderer } from "@downcity/city/power/react";
export default define_power_renderer({ config: function GithubConfig({ config, ui }) {
const { Button, Group, Input, LoadingState, Page, Row, Toolbar } = ui.components;
const [config_state, set_config_state] = useState<{ api_url: string }>();
useEffect(() => {
void config.invoke<{ api_url: string }>("config.read").then(set_config_state);
}, [config]);
if (!config_state) return ;
return
void config.invoke("config.save", config_state).then(() => ui.toast({ type: "success", message: "已保存" }))}>保存} />
set_config_state({ ...config_state, api_url })} />} />
;
} });
```
Renderer bundle 必须把 `react` 与 `react/jsx-runtime` 保留为宿主外部依赖,并分别映射到 `downcity-power://runtime/react` 与 `downcity-power://runtime/react/jsx-runtime`。其他前端依赖应打包进同一个 `renderer.js`。
Desktop 在宿主 React 树中加载 Renderer。Sidebar 与 Mainview 通过 `power.invoke()` 调用 Power 级动作,并通过 `navigation` 协同导航;Config 通过 `config.invoke()` 调用当前 Power 的配置动作。每个插槽都只获得所需 gateway 与宿主 UI,不会获得 Desktop controller。Renderer 属于受信任本地 UI 代码;只安装可信来源。
Sidebar 与 Mainview 还会获得当前 Power 的只读 `notifications` 数组。每项包含 Power 本地 `topic_key`、文案、route 和产生时间,可用于在对应导航对象旁显示未读状态。Renderer 通过导航到通知的完整 route 触发宿主已读收口,但没有发布或修改通知的权限。
需要在 Sidebar 标题栏提供创建入口时,可以向 `Sidebar` 的 `actions` 传入宿主提供的 `SidebarCreateMenu`。宿主负责标题栏、加号与下拉菜单的统一视觉,Power 只声明创建类型以及选择后的业务导航。
需要树状 Sidebar 时使用 `SidebarTreeItem`:`depth` 表达层级,`kind` 区分 `branch` 与 `leaf`,父节点通过 `expanded` 与 `on_toggle` 管理展开,`on_select` 通过共享 `navigation` 更新 Mainview。省略 `leading` 时宿主会为分支和叶子节点提供统一默认图标;没有子项的分支可以省略 `on_toggle`,此时不会显示折叠按钮。历史记录等多级导航也应继续放在 Power Sidebar 的树中,不占用 Mainview。行级操作使用 `ItemMenu`,Markdown 正文使用宿主安全的 `Markdown`。写操作完成后调用 `ui.invalidate()`,宿主会推进同一 Power 在 Sidebar、Mainview、Config 与 Agent Profile 页签间共享的 `ui.revision`。后台刷新应就地更新快照,保留已有数据与打开的阅读器;`agent_id` 变化时重置 Agent 专属状态。刷新状态不写入 route。
### Agent Profile 页签
通过 `agent_config: { id, label, component }` 贡献一个 Agent Profile 页签。`id` 与 `label` 必须是非空字符串。宿主收集已启用 Power 的声明,并为页签 ID 添加 Power 命名空间。Catalog 刷新时,只要同一 Renderer 注册仍然可用,宿主会保留当前页签选择与已挂载的组件。不需要额外的 `power.json` 能力标记,此插槽可以独立于 Sidebar、Mainview 和 Config 声明。
```tsx
import { define_power_renderer } from "@downcity/city/power/react";
import type { PowerAgentConfigComponentProps } from "@downcity/city/power/react";
function AgentSettings({ agent_id, ui }: PowerAgentConfigComponentProps) {
const { Page, Row } = ui.components;
return
;
}
export default define_power_renderer({
agent_config: { id: "settings", label: "设置", component: AgentSettings },
});
```
组件只接收 `{ agent_id, power, ui }`。`agent_id` 标识当前配置的 Agent。`power.invoke(action_id, input)` 与 Mainview 一样使用所属 Power 的业务 action gateway,不使用 Config gateway。Power 负责校验目标 Agent 与动作输入。此插槽不接收 `navigation` 或 `notifications`。
`PowerAgentConfigDefinition`、`PowerAgentConfigComponent`、`PowerAgentConfigComponentProps` 均从 `@downcity/city/power/react` 导出。布局使用 `ui.components`,写操作后调用 `ui.invalidate()`;宿主推进该 Power 的 `ui.revision`,供各界面重新读取数据。内建 Skill Power 通过此通用插槽提供 **Agent Profile → Skills**,管理技能关联与 MCP 分配。
## Config 交互
```text
进入 Powers Catalog 并选择 Power
→ 在 Config 中编辑 Power 的唯一配置
→ 保存后由 Power 刷新受影响的长期资源
```
每个 Power ID 只有一份 Config,与 Sidebar/Mainview 无关。宿主只负责持久化,不解释字段,也不根据 JSON Schema 生成表单。内容结构、校验、默认值、Secret 行为和保存后的资源刷新都属于 Power。
配置保存在 `~/.downcity/powers//config.toml`,目录权限为 `0700`,文件权限为 `0600`。当前存储使用 TOML,因此配置必须是 TOML 可表达的 JSON object。Agent 的 `agent.json` 不保存 Power 引用;City 自动把全部 Power 和同一份配置提供给所有 Agent。
## 安装
```bash
city power install ./github-power
# 在 Desktop Powers Catalog 的 GitHub 详情中配置
city power config github --set '{"api_url":"https://api.github.com"}'
city power update github
city power uninstall github
```
安装器只复制 `power.json`、`package.json`、`readme` 声明的 Markdown 文件、运行入口和可选本地图标,不执行依赖安装、构建脚本或任何入口。所有本地路径都不能逃逸 Power 根目录或使用 symlink,安装制品会生成 SHA-256 完整性摘要。只安装可信来源。
继续阅读:
- [CLI 命令](/zh/docs/cli/commands)
- [Agent Powers Docs](/zh/powers-docs)
### City
Source: https://downcity.ai/zh/docs/city/overview/
**City** 是 Downcity 的本地 Agent 宿主容器。它在内存中持有多个 Agent,并把 HTTP/RPC 请求转发到指定 Agent。
## City 负责什么
- **Agent 集合** — `city.agents.add(agent)` 接纳已创建的 Agent,`city.agents.get(agent_id)` 按 ID 读取,`city.agents.list()` 列出全部实例。
- **HTTP/RPC 转发** — City 统一提供两个端口,通过 `agent_id` 把请求转发到对应 Agent。
- **宿主生命周期** — CLI/Desktop 负责创建和释放 City、Agent、Workspace 与平台资源;City 负责自己的 Server 生命周期。
## City 不负责什么
- 模型目录、身份认证、用量和计费,这些属于 Federation/Embassy。
- Agent 配置的数据库适配和 Workspace 创建,这些属于宿主的组合根。
- Power 的具体存储实现,这些属于对应 Power Provider/Adapter。
这些属于 Agent 运行时。
## Agent 如何连接 City
当你运行 `city on` 时,CLI 从全局 DB 读取配置,显式创建全部 Agent,再注册到一个 `City`,最后启动 City 的 HTTP/RPC Server。
当你直接使用 SDK 时,可以直接创建 `Agent`;需要统一 HTTP/RPC 转发时,再把 Agent 注册到 `City`。
## 继续阅读
- [City SDK](/zh/city-sdk-docs) — 构建在 City 基础设施上的 SDK
- [Payments](/zh/payments) — 账号、余额、用量和支付 API
- [Agent SDK](/zh/agent-sdk-docs) — Agent 如何在运行时消费 City 服务
### 模型池
Source: https://downcity.ai/zh/docs/city/model-pool/
City 通过 Federation AIService 拥有模型目录。Agent 不直接嵌入提供商密钥或模型定义。相反,它们通过 ID 绑定模型,City 在运行时将 ID 解析为实时模型实例。
## 工作原理
1. City 注册模型提供商(OpenAI、Anthropic、Google、本地模型等)
2. 每个提供商暴露能力和模型 ID
3. Federation AIService 维护统一目录
4. Agent 绑定到类似 `"default"` 或 `"quality"` 的模型 ID
5. 运行时,ID 被解析为实时模型实例
## 模型绑定
对于 CLI 管理项目,绑定保存在 CLI 全局 DB 的 Agent 配置中:
```json
{
"execution": {
"type": "api",
"modelId": "quality"
}
}
```
对于 SDK 项目,模型直接传入构造函数或 session。
## 提供商管理
City 处理:
- 提供商注册和认证
- 模型能力发现
- 按提供商的用量计量
- 提供商间故障转移
继续阅读:
- [City 身份](/zh/docs/city/identity)
- [用量与计费](/zh/docs/city/usage-billing)
### 身份
Source: https://downcity.ai/zh/docs/city/identity/
Federation 统一签发用户身份并保存账户数据。客户端通过 Embassy 直接访问
Federation;产品需要自己的后端能力时,再配置对应 Bureau 的可信业务入口。
## 身份边界
- **Federation**:服务端运行容器,签发 `user_token`,管理账户、Service 和 Bureau 记录。
- **Embassy User**:保存当前 `user_token`,执行用户登录和业务请求。
- **Embassy Admin**:使用管理员 Session 管理 Federation,包括授权 Federation 签发 Bureau Token。
- **Bureau**:由 `bureau_id` 区分的产品业务分区;产品后端使用独立 Bureau 客户端证明机器身份。
Embassy 只有 `user` 和 `admin` 两个身份子域:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
});
```
Embassy 本身不绑定 Bureau。登录流程需要产品分区时,才在本次登录中传入 `bureau_id`。
## 用户身份
账户流程统一位于 `embassy.user.account`。登录成功后,同一个 Embassy 实例会立即使用
新的 User Token:
```ts
const providers = await embassy.user.account.providers();
await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: {
email: "user@example.com",
password: "password",
},
});
const current_user = await embassy.user.current();
const user_token = embassy.user.account.token();
```
邮箱须完成验证后才能登录,SDK 自动交换已完成流程的 Token。`await embassy.user.account.logout()` 先撤销当前 Token 再清理本地凭证,失败保留凭证以便重试。Bureau 对新的受保护请求向 Federation 在线验证,检查不可用时拒绝访问;已开始的流式请求继续运行。
User Token 是 Federation 使用 Ed25519 私钥签发的 JWT。它包含 `user_id`、
`bureau_id`、issuer、audience 和有效期;公开验签密钥由以下接口发布:
```text
/.well-known/downcity.json
/.well-known/jwks.json
```
用户侧 AI、Payment 和普通 Service 请求直接发送到 Federation:
```ts
const models = await embassy.user.ai.catalog();
const accounts = embassy.user.service("accounts");
const services = await embassy.user.list_services();
```
当当前 Bureau 配置了可信 `server_url` 时,相对路径请求会发送到该 origin,并自动携带
当前 User Token。Embassy 不会把 Token 转发到其他 origin:
```ts
const result = await embassy.user.post(
"/reports/summary",
{ range: "today" },
);
```
## 管理员身份
管理员使用 ID 和密码换取有期限的 Admin Session。不存在 Root Admin Token:
```ts
await embassy.admin.login({
admin_id: "owner",
password: "password",
});
const current_admin = await embassy.admin.current();
const bureaus = await embassy.admin.bureaus.list();
```
密码遗失时,由拥有部署权限的人运行 `fed deploy --admin-reset` 重设管理员凭证。
## Bureau Token
登录后的 Federation Admin 可以签发 Bureau Token:
```bash
fed bureau token
```
CLI 请求 Federation 生成高熵 Token。Federation 只保存 `token_id`、用途、hash 和生命周期状态,
明文只返回一次,应写入业务服务的部署环境:
```bash
DOWNCITY_FEDERATION_URL=https://fed.example.com
DOWNCITY_BUREAU_TOKEN=fb_br_xxx.secret
```
业务服务通过独立的 `Bureau` 客户端验证收到的 Federation User Token:
```ts
import { Bureau } from "@downcity/federation";
const bureau = new Bureau({
federation_url: process.env.DOWNCITY_FEDERATION_URL!,
bureau_token: process.env.DOWNCITY_BUREAU_TOKEN!,
});
const identity = await bureau.identify(request);
```
`bureau_token` 是长期、可撤销的不透明 Bearer 凭证,不是 JWT,也不能签发 User Token。
它在创建时绑定 `bureau_id`;`identify()` 会验证签名、issuer、audience、有效期和该
`bureau_id`。发生泄露时,使用 `fed bureau token revoke ` 立即撤销。
User Token 的签发权威始终是 Federation。账户登录或 Admin 管理接口都只是进入 Federation
签发流程的入口,Bureau Token 无权调用 User Token 签发接口。
| 凭证 | 持有者 | 用途 | 验证方式 |
| --- | --- | --- | --- |
| `user_token` | 用户客户端 | 访问 Federation 与 Bureau 业务 | Federation JWKS 验签 |
| `admin_token` | 管理员 Embassy | Federation 控制面 | Federation Admin Session |
| `bureau_token` | Bureau 业务服务 | 证明机器身份并执行 `identify()` | Federation 比较凭证 hash |
继续阅读:
- [Federation SDK](/zh/city-sdk-docs/packages/federation)
- [模型池](/zh/docs/city/model-pool)
- [用量与计费](/zh/docs/city/usage-billing)
### 用量与计费
Source: https://downcity.ai/zh/docs/city/usage-billing/
City 计量每次模型调用并追踪所有已连接 Agent 和产品的用量。这是计费、配额和成本管理的基础。
## 用量追踪
- 每次模型请求都会被计量(输入 token、输出 token)
- 用量按用户、按项目和按模型聚合
- 控制台中提供实时用量仪表板
持有有效 Admin Session 的 Federation 管理员可以读取服务端聚合结果:
```text
GET /v1/usage/admin/overview?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
GET /v1/usage/admin/users?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
GET /v1/usage/admin/retention?from=2026-07-07&to=2026-08-05&timezone=Asia/Shanghai
```
`overview` 返回范围活跃、真实去重的 DAU/WAU/MAU 日序列、调用结果、Token 构成、
Credits、模型与 Action 分布、当地小时分布,以及平均/P50/P95 执行耗时。`users` 按用户
返回调用次数、Input/Output/Total Token、成功率、Top Model、Credits、耗时与最后活跃时间。
`retention` 使用 Accounts 注册时间和真实 AI Usage 活跃日计算 D1、D3、D7、D14、D30
注册 Cohort 留存。活跃用户定义为统计窗口内至少产生一次 AI Usage Record 的用户;失败和
取消调用仍表达真实产品使用,因此计入活跃,并单独进入结果与成功率。
## 余额
- 用户在 City 中维护余额账户
- 余额随着用量产生而消耗
- 余额低时触发提醒
- 可以配置自动充值
## 计费
City 与 Stripe 集成,支持:
- 一次性支付
- 订阅计划
- 基于用量的计费
- 发票生成
## 配额
- 软配额在达到限制前发出警告
- 硬配额在超出时阻止请求
- 配额可以按用户、按项目或按模型配置
继续阅读:
- [模型池](/zh/docs/city/model-pool)
- [City 身份](/zh/docs/city/identity)
### Federation & Service
Source: https://downcity.ai/zh/docs/federation/overview/
**Federation** 是 City 连接的共享服务端运行容器。它负责用户身份、City 隔离、Service 路由、模型目录、用量和支付,并通过统一接口连接多个 AI 提供商。
## Federation AIService
Federation AIService 是 City 的一个组件,负责:
- 注册模型提供商及其能力
- 无论后端是什么,都暴露统一的模型 API
- 追踪用量并将请求路由到合适的提供商
- 处理故障转移和速率限制
Agent 通过类似 `"default"` 或 `"quality"` 的 ID 绑定模型。Federation AIService 在运行时将该 ID 解析为实时模型实例。
## Service / Action 基础设施
City 还提供了一个通用的 **Service** 和 **Action** 层,用于构建 Agent 可调用的后端能力:
- **Service** — 长期运行的后端能力(例如知识库、搜索引擎、数据库)
- **Action** — Agent 可调用的离散操作(例如发送邮件、查询数据库、调用外部 API)
Service 和 Action 向 City 注册,可被任何已连接的 Agent 发现和调用。
## 与 City 的关系
`Federation` 是服务端,`City` 是携带 `user_token` 的用户客户端。当你使用 City SDK 时,你在交互的是:
- City 身份与认证
- Federation AIService(模型目录和路由)
- Service/Action 基础设施(后端能力)
- 用量、余额和计费(City Services)
Federation 首次启动会自动生成 Ed25519 Key Ring,并通过 `/.well-known/jwks.json` 发布公开验签密钥。Embassy 可以直接访问 Federation;产品需要自有后端能力时,再使用 Bureau Token 和独立 Bureau 客户端本地验证用户 Token。Federation 启动不会默认创建 Bureau Token。
## 管理员登录与恢复
首次运行 `fed deploy` 时,CLI 会交互式要求设置 Federation 管理员 ID、密码和确认密码。密码至少 12 个字符,Federation 数据库仅保存加盐密码摘要。运行 `fed web` 后,在登录页输入这组凭证;浏览器不会接触远端管理 Session Token,Token 只存在于本机 BFF 进程内。
普通部署不会修改管理员。密码遗失时,只有拥有 Local 主机权限或 Cloudflare Worker/D1 部署权限的人可以执行:
```bash
fed deploy --admin-reset
```
非交互环境还必须显式添加 `--yes`;该参数只确认重置,不跳过管理员凭证输入。恢复由 CLI 使用本地 SQLite 事务或 Wrangler 远程 D1 权限直接写入管理员数据库,不需要在 Worker 中接收恢复环境变量,也不会把明文密码传给数据库。恢复成功会撤销全部旧管理员会话。Federation 不提供匿名密码重置 API。
`fed deploy` 会在交互终端持续显示当前步骤与耗时,包括构建、类型检查、Cloudflare 账号检查、D1 / Queue / R2 准备、Worker 发布、健康检查和管理员验证;CI 与重定向输出使用稳定的逐行阶段日志。
`fed bureau token` 会打开交互式管理界面;选择签发或运行 `fed bureau token issue `
后,CLI 会要求输入用途并使用 Admin Session 请求 Federation 签发。Federation 生成明文,
数据库只保存用途、hash 与生命周期状态。明文只返回一次,由运维写入 Bureau 服务器的
`DOWNCITY_BUREAU_TOKEN`;Federation 和 Bureau 不需要部署在同一台服务器。Bureau Token
只证明机器身份,不能签发 User Token。
## 继续阅读
- [City SDK](/zh/city-sdk-docs) — 构建在 City 和 Federation 之上
- [Payments](/zh/payments) — 用量、余额和计费 API
### Organizations
Source: https://downcity.ai/zh/docs/federation/organizations/
`OrganizationsService` 管理 Organization、Membership、Join Request 和组织治理角色。Federation 是这些关系的唯一权威来源。
Organization 不保存产品后端地址,也不管理 Space、文档、Repository 等产品资源。Bureau 从产品可信配置取得 Bureau 地址,并携带当前 `user_token` 请求 Bureau。Bureau 验证用户身份、查询 Membership,再执行自己的资源权限。
- 客户端和产品后端 API:[Organizations Federation API](./organizations-api)
- 产品后端接入:[Bureau 接入 Organizations](./organizations-bureau)
## 安装
```ts
import { Federation } from "@downcity/federation";
import { OrganizationsService } from "@downcity/services";
const federation = new Federation({ database });
federation.use(new OrganizationsService({
max_organizations_per_user: 3,
}));
```
额度统计用户作为 Owner 持有的全部 active Organization,不按 Bureau 重复计算。归档会释放额度,Owner 转移会把额度交给新 Owner。
## Organization 作用域
创建 Federation 全局 Organization:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: process.env.DOWNCITY_FEDERATION_URL!,
user_token,
});
await embassy.user.service("organizations").action("create").invoke({
name: "Genesis Research",
scope_type: "federation",
});
```
创建当前 Bureau Organization:
```ts
await embassy.user.service("organizations").action("create").invoke({
name: "Genesis Research",
scope_type: "bureau",
});
```
`federation` Organization 可以由同一 Federation 下任意 Bureau Token 中的成员访问。`bureau` Organization 只接受 `user_token.bureau_id` 与 `scope_bureau_id` 相同的请求。Bureau 作用域从已验证 Token 写入,客户端不能提交或覆盖 `scope_bureau_id`。
全局不等于公开。Organizations Service 不提供公开搜索;用户仍需通过邀请链接、二维码或产品流程取得 `organization_id`。
## Membership
Membership 表示一个 User 与 Organization 之间的一段成员关系。每次成功加入都会生成新的 `membership_id`。退出或被移除后,旧记录永久保持 `removed`;重新加入会创建另一条 Membership。
```ts
await embassy.user.service("organizations").action("join-requests/create").invoke({
organization_id: "org_01J...",
});
```
Owner、Admin、Member 只表达 Organization 治理角色,不代表产品中的 editor、viewer 或 repository push 权限。
## 调用产品 Bureau
Bureau 把同一个 `user_token` 发送给产品预先配置的 HTTPS Bureau:
```ts
await embassy.user.post("/v1/projects", {
organization_id: "org_01J...",
});
```
Bureau 使用 Federation JWKS 验证 Token,并通过 `membership/get` 查询当前 Membership。Organizations Service 不再签发 `organization_token`,也不向用户提供的 URL 投递撤权事件。
## 数据库与归档
Organizations Service 支持 better-sqlite3、PostgreSQL 与 Cloudflare D1。三种 Runtime 统一使用 `context.transaction()`;Service 不判断数据库类型。
Organization 归档不可恢复。归档会停止治理写入并释放 Owner 额度。产品资源是否保留、归档或删除,由 Bureau 决定。
### Organizations Federation API
Source: https://downcity.ai/zh/docs/federation/organizations-api/
这篇文档描述客户端和 Bureau 可以调用的 Organizations Action。产品后端的身份和权限接入见 [Bureau 接入 Organizations](./organizations-bureau)。
## 安装
```ts
import { Federation } from "@downcity/federation";
import { OrganizationsService } from "@downcity/services";
const federation = new Federation({ database });
federation.use(new OrganizationsService({
max_organizations_per_user: 3,
}));
```
| 选项 | 必填 | 说明 |
| --- | --- | --- |
| `max_organizations_per_user` | 是 | 一个用户在 Federation 中最多拥有的 active Organization 总数 |
Service 支持 better-sqlite3、PostgreSQL 和 Cloudflare D1,并统一使用 `context.transaction()`。
## 地址与鉴权
```text
{federation_origin}/v1/organizations/{action}
```
所有 Action 都需要 Federation `user_token`:
```http
Authorization: Bearer ub_
```
Federation 从已验证 Token 读取 `user_id` 和 `bureau_id`。GET Action 使用 Query,POST Action 使用 JSON。
## 类型
```ts
type Organization = {
organization_id: string;
name: string;
scope_type: "federation" | "bureau";
scope_bureau_id: string;
state: "active" | "archived";
created_by: string;
created_at: string;
updated_at: string;
archived_at: string;
};
type Membership = {
membership_id: string;
organization_id: string;
user_id: string;
role: "owner" | "admin" | "member";
state: "active" | "removed";
created_at: string;
updated_at: string;
removed_at: string;
removed_by: string;
};
type JoinRequest = {
request_id: string;
organization_id: string;
user_id: string;
state: "pending" | "approved" | "rejected" | "canceled";
requested_at: string;
decided_at: string;
decided_by: string;
};
```
`scope_type = federation` 时 `scope_bureau_id` 为空字符串。`scope_type = bureau` 时,`scope_bureau_id` 来自创建请求的 `user_token.bureau_id`。
## 权限
| 操作 | Owner | Admin | Member |
| --- | --- | --- | --- |
| 读取 Organization 和成员 | ✓ | ✓ | ✓ |
| 修改名称 | ✓ | ✓ | |
| 审批 Join Request | ✓ | ✓ | |
| 移除 Member | ✓ | ✓ | |
| 移除 Admin | ✓ | | |
| 任命或撤销 Admin | ✓ | | |
| 转移 Owner | ✓ | | |
| 归档 | ✓ | | |
| 主动退出 | 转移后 | ✓ | ✓ |
## Action
| Method | Action | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `my` | User | 列出当前用户可见的 Organization |
| `GET` | `get` | Member | 读取 Organization 与当前 Membership |
| `POST` | `create` | User | 创建 Organization 并成为 Owner |
| `POST` | `update` | Owner/Admin | 修改名称 |
| `POST` | `archive` | Owner | 归档 Organization |
| `GET` | `membership/get` | Member | 读取当前 Membership |
| `GET` | `members/list` | Member | 列出 active Membership |
| `POST` | `members/role` | Owner | 设置 Admin 或 Member |
| `POST` | `members/remove` | Owner/Admin | 移除可管理的成员 |
| `POST` | `members/leave` | Admin/Member | 主动退出 |
| `POST` | `owner/transfer` | Owner | 转移唯一 Owner |
| `POST` | `join-requests/create` | User | 申请加入 |
| `POST` | `join-requests/cancel` | Applicant | 取消 pending 申请 |
| `GET` | `join-requests/list` | Owner/Admin | 列出 pending 申请 |
| `POST` | `join-requests/decide` | Owner/Admin | 批准或拒绝申请 |
## Organization Action
### `GET my`
```http
GET /v1/organizations/my
```
默认只返回 active Organization。包含归档记录:
```http
GET /v1/organizations/my?include_archived=true
```
返回 `{ "items": UserOrganization[] }`。Federation Organization 在任意 Token Bureau 可见;Bureau Organization 只在当前 Token Bureau 匹配时可见。
### `GET get`
```http
GET /v1/organizations/get?organization_id=org_01J...
```
要求 active Membership,返回 `{ organization, membership }`。成员可以读取 archived Organization,但不能继续治理写入。
### `POST create`
创建全局 Organization:
```json
{
"name": "Genesis Research",
"scope_type": "federation"
}
```
创建当前 Bureau Organization:
```json
{
"name": "Genesis Research",
"scope_type": "bureau"
}
```
Service 返回 `{ organization, membership }`,Membership 是唯一 Owner。名称去除首尾空白后必须为 1–120 个字符。
### `POST update`
```json
{
"organization_id": "org_01J...",
"name": "New Name"
}
```
Owner/Admin 可修改名称。Organization 作用域创建后不能修改。
### `POST archive`
```json
{ "organization_id": "org_01J..." }
```
Owner 可执行。归档不可恢复,会停止治理写入并释放 Owner 额度。Membership 和 Join Request 历史保留。
## Membership Action
### `GET membership/get`
```http
GET /v1/organizations/membership/get?organization_id=org_01J...
```
返回 `{ organization, membership }`。Bureau 可以携带用户提交的同一个 `user_token` 调用此接口。被移除的用户返回 `403 NOT_AN_ORGANIZATION_MEMBER`。
### `GET members/list`
```http
GET /v1/organizations/members/list?organization_id=org_01J...
```
返回 `{ "items": Membership[] }`,只包含 active Membership。
### `POST members/role`
```json
{
"organization_id": "org_01J...",
"membership_id": "mem_01J...",
"role": "admin"
}
```
Owner 可将非 Owner 成员设置为 `admin` 或 `member`。转移 Owner 使用 `owner/transfer`。
### `POST members/remove`
```json
{
"organization_id": "org_01J...",
"membership_id": "mem_01J..."
}
```
Owner 可以移除 Admin 或 Member;Admin 只能移除 Member。唯一 Owner 不能被移除。
### `POST members/leave`
```json
{ "organization_id": "org_01J..." }
```
Admin 和 Member 可以退出。Owner 必须先转移所有权。
### `POST owner/transfer`
```json
{
"organization_id": "org_01J...",
"membership_id": "mem_new_owner"
}
```
目标必须是 active Member,并且还有 Owner 额度。原 Owner 变为 Admin。
## Join Request Action
### `POST join-requests/create`
```json
{ "organization_id": "org_01J..." }
```
用户通过已知 ID 申请加入。重复申请返回同一个 pending 记录。已有 active Membership 时返回 `{ state: "joined", organization, membership }`。
### `POST join-requests/cancel`
```json
{ "request_id": "join_01J..." }
```
申请人可以取消自己的 pending 申请。
### `GET join-requests/list`
```http
GET /v1/organizations/join-requests/list?organization_id=org_01J...
```
Owner/Admin 调用,返回 pending Join Request。
### `POST join-requests/decide`
```json
{
"request_id": "join_01J...",
"decision": "approved"
}
```
`decision` 接受 `approved` 或 `rejected`。批准时,Service 在同一事务中创建新的 Member Membership。
## 错误
| HTTP | Code | 含义 |
| --- | --- | --- |
| `400` | `ORGANIZATION_INPUT_INVALID` | 名称、ID、角色或决策无效 |
| `400` | `ORGANIZATION_SCOPE_INVALID` | `scope_type` 无效 |
| `401` | `AUTH_REQUIRED` | 缺少有效用户身份 |
| `403` | `ORGANIZATION_BUREAU_MISMATCH` | Token Bureau 不能访问该 Bureau Organization |
| `403` | `NOT_AN_ORGANIZATION_MEMBER` | 当前用户没有 active Membership |
| `403` | `ORGANIZATION_ROLE_DENIED` | 治理角色不允许操作 |
| `404` | `ORGANIZATION_NOT_FOUND` | Organization 不存在 |
| `404` | `ORGANIZATION_MEMBERSHIP_NOT_FOUND` | Membership 不存在或已失效 |
| `404` | `JOIN_REQUEST_NOT_FOUND` | Join Request 不存在或不可操作 |
| `409` | `ORGANIZATION_LIMIT_REACHED` | Owner 额度已满 |
| `409` | `OWNER_TRANSFER_REQUIRED` | 必须先转移 Owner |
| `410` | `ORGANIZATION_ARCHIVED` | Organization 已归档 |
客户端应按 `error.code` 分支,不要依赖错误文本。
### Bureau 接入 Organizations
Source: https://downcity.ai/zh/docs/federation/organizations-bureau/
Bureau 是可信产品后端。Bureau 向产品预先配置的 HTTPS Bureau 发送 Federation `user_token`,Bureau 先验证用户和 Bureau,再查询 Organization Membership。
## 授权顺序
```text
Bureau
-> user_token + 产品请求
-> Bureau 验证 Federation JWT 和 bureau_id
-> Bureau 从资源记录读取 organization_id
-> Bureau 查询 Federation Membership
-> Bureau 执行产品角色与资源权限
```
Membership 通过只代表用户属于 Organization。它不代表用户可以访问 Organization 下的每个产品资源。
## 验证 `user_token`
Bureau 使用预先配置的 Federation issuer 和 JWKS 校验:
- JWT 算法与签名;
- `iss` 和 `aud`;
- `exp`、`nbf`;
- `user_id` 和 `bureau_id`;
- `bureau_id` 是否属于当前产品允许的范围。
Bureau 不应记录 Authorization Header,也不能把 Token 转发给产品配置之外的服务。
## 查询 Membership
Bureau 可以携带用户提交的同一个 Token 查询 Federation:
```http
GET /v1/organizations/membership/get?organization_id=org_01J...
Authorization: Bearer ub_...
```
成功响应包含当前 Organization 与 active Membership。用户退出或被移除后,接口返回 `403 NOT_AN_ORGANIZATION_MEMBER`。
## 检查资源归属
产品资源表保存 `organization_id`:
```sql
CREATE TABLE projects (
project_id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL
);
```
处理 `/v1/projects/:project_id` 时,Bureau 先读取 Project,再用 `project.organization_id` 查询 Membership。不能相信客户端额外提交的 Organization ID 来决定资源归属。
## 产品权限
Bureau 在 Membership 通过后执行自己的权限模型:
```text
Federation Membership
-> 用户属于 Organization
Product Role / ACL
-> 用户能否读取、编辑或管理具体资源
```
Organization Owner/Admin/Member 只负责 Federation 中的组织治理。产品需要的 editor、viewer、billing_admin 等角色由 Bureau 保存。
## 缓存与撤权
要求即时撤权的写入、管理或敏感读取应在线查询 Federation。低风险读取可以使用短时缓存,最长撤权延迟等于缓存 TTL。
缓存键至少包含 Federation issuer、`user_id` 和 `organization_id`。Bureau 必须为缓存 TTL 做出明确选择,Organizations Service 不再维护跨服务器撤权 Outbox。
## 检查清单
- [ ] Bureau URL 来自可信产品配置
- [ ] 只通过 HTTPS 接收 `user_token`
- [ ] 校验 issuer、audience、签名、时间和 `bureau_id`
- [ ] 不记录或转发 Authorization Header
- [ ] 从资源记录读取 `organization_id`
- [ ] 对敏感操作在线查询 Membership
- [ ] Membership 通过后继续执行产品权限
### 模型提供商
Source: https://downcity.ai/zh/docs/federation/model-provider/
Federation AIService 抽象了多个 AI 模型提供商。模型提供商是任何能够服务模型请求的后端 —— OpenAI、Anthropic、Google 或本地推理服务器。
## 注册
提供商向 City 注册,暴露:
- 可用模型 ID
- 能力标志(流式、工具使用、视觉等)
- 认证要求
## 统一 API
无论后端是什么,Federation AIService 向 Agent 暴露统一的 API。这意味着:
- Agent 不需要提供商专属代码
- 切换提供商是配置变更,不是代码变更
- 可以添加新提供商而不修改 Agent
## 能力发现
当 Agent 绑定到模型 ID 时,City 报告:
- 模型是否支持流式
- 模型是否支持工具使用
- 最大上下文长度
- 每 token 定价
继续阅读:
- [Service](/zh/docs/federation/service)
- [Action](/zh/docs/federation/action)
### 本地代理
Source: https://downcity.ai/zh/docs/federation/local-proxy/
当你需要一个本地 Federation 服务,只暴露 Codex 或 Claude Code 的模型能力,而不注册到已有 Fed server 时,可以使用 Downcity local proxy skill。
生成的项目会启动:
- `127.0.0.1:8317` 上的 CLIProxyAPI
- `127.0.0.1:3000` 上的 Downcity 本地 Federation
- `/v1/ai/models` 和 `/v1/ai/chat/completions` 模型接口
## 安装 skill
在包含 `private` 仓库的联合工作区根目录,使用 Vercel Labs `skills` CLI 从本地资源安装:
```bash
npx skills add ./private/apps/homepage/resources/downcity-local-proxy-skill \
--skill downcity-local-proxy \
--agent codex \
--global \
--yes
```
## 让 Codex 创建本地服务
安装后,对 Codex 发送这个 prompt:
```text
Use downcity-local-proxy to create ./downcity/local-proxy with Codex and Claude Code enabled, then install dependencies and start the local Federation service.
```
## 手动启动
skill 会创建一个独立项目。进入生成目录后运行:
```bash
cd ./downcity/local-proxy
pnpm install
pnpm start --codex
pnpm start --claude-code
pnpm start --codex --claude-code
```
当 CLIProxyAPI 需要 OAuth 登录或刷新时,添加 `--login`:
```bash
pnpm start --codex --login
pnpm start --claude-code --login
```
本地代理只会在生成项目内的 `.downcity/` 写入运行时文件,不会修改全局 Downcity CLI 状态,也不会把自己注册到已有 Fed server。
继续阅读:
- [模型提供商](/zh/docs/federation/model-provider)
- [Service](/zh/docs/federation/service)
### Service
Source: https://downcity.ai/zh/docs/federation/service/
**Service** 是 Agent 可以调用的长期运行后端能力。与工具(在 Agent 进程内运行)不同,Service 作为独立后端运行,通过 City 网络访问。
## 示例
- 知识库(文档的语义搜索)
- 搜索引擎(网页搜索、内部搜索)
- 数据库(结构化数据存储和查询)
- 文件存储(文档上传和检索)
## 注册
Service 向 City 注册,通过:
1. 声明其 API schema
2. 暴露健康和能力的端点
3. 接收一个 service ID
## 发现
Agent 通过 City 发现 Service。当 Agent 需要某种能力时,它向 City 询问可用的 Service,然后将请求路由到合适的那个。
继续阅读:
- [模型提供商](/zh/docs/federation/model-provider)
- [Action](/zh/docs/federation/action)
### Action
Source: https://downcity.ai/zh/docs/federation/action/
**Action** 是 Agent 可以调用的离散操作。它是 Service/Action 层中的工作单元。Action 由 Service 暴露,可以组合成工作流。
## 示例
- `send_email` — 向收件人发送邮件
- `query_database` — 运行 SQL 查询
- `call_api` — 调用外部 HTTP API
- `upload_file` — 将文件存储到文件存储
## Schema
每个 Action 声明:
- 输入 schema(接受的参数)
- 输出 schema(返回的内容)
- 错误条件
- 所需权限
## 调用
Agent 通过 City 层调用 Action。City 将请求路由到拥有该 Action 的 Service,然后将结果返回给 Agent。
## 组合
Action 可以组合成更高级的工作流。例如,"用户入职"工作流可能按顺序调用 `create_account`、`send_welcome_email` 和 `add_to_crm`。
继续阅读:
- [模型提供商](/zh/docs/federation/model-provider)
- [Service](/zh/docs/federation/service)
### 路由
Source: https://downcity.ai/zh/docs/federation/routing/
Federation AIService 处理模型提供商和 Service 之间的请求路由和故障转移。这确保即使个别后端宕机,Agent 仍然可用。
## 模型路由
当 Agent 发送模型请求时:
1. Federation AIService 查找模型 ID
2. 选择健康和延迟最低的提供商
3. 转发请求
4. 如果提供商失败,用下一个可用提供商重试
## Service 路由
当 Agent 调用 Action 时:
1. City 将 Action 解析到拥有它的 Service
2. 检查 Service 的健康和负载
3. 将请求路由到最佳实例
4. 如果 Service 不可用,返回错误或排队请求
## 故障转移
- 持续监控提供商健康
- 失败的请求以指数退避重试
- 断路器防止级联故障
- 负载均衡将流量分布到健康实例
继续阅读:
- [模型提供商](/zh/docs/federation/model-provider)
- [Service](/zh/docs/federation/service)
### 架构
Source: https://downcity.ai/zh/docs/concepts/architecture/
当前 Downcity 最好通过五层来理解:
1. `console`
2. `agent`
3. `execution context`
4. `session`
5. `power runtime`
## 分层
### `console`
- 全局控制面
- 进程 registry
- city 级配置与观测
### `agent`
- 拥有一个项目 runtime
- 持有模型访问、session store、Power registry 与 runtime host 状态
### `execution context`
- 执行期间暴露出来的统一能力面
- Power 读取稳定运行时能力的地方
### `session`
- 拥有一条执行线程与其 prompt / run 状态
- 真正跑 model + tool flow 的执行单元
### `power runtime`
- 当前所有能力模块统一建模成 Power
- 有些是本地 action-first Power
- 有些是托管 lifecycle-owned Power
## 托管 Power 与本地 Power
### 本地 Power
- 通常直接挂到一个 SDK `Agent` 上
- 例如:`skill`、`web`、`sound`
### 长期运行 Power
- 由 `Agent` 在构造时装配,并在 `agent.dispose()` 时释放
- 例如:`chat`、`task`、`memory`、`shell`、`schedule`
## 最重要的结论
不要再用旧的“双层拆分”语言理解当前系统。
现在的真实模型是:
- session 负责执行
- Power 负责暴露能力
- 托管 Power 负责长期运行模块
### Project Logic Overview
Source: https://downcity.ai/zh/docs/concepts/project-logic-overview/
理解 Downcity 最好先从产品问题开始:AI builders 不应该为每个产品、工作流或客户 Demo 都重复搭一遍 Agent 后端。
核心关系是:
- 产品表层负责用户体验
- Agent 在明确运行边界里执行任务
- Downcity runtime services 负责模型、env、auth、service routing、usage、balance、payment 等重复基础设施
- Console、Chrome Extension、SDK 和 UI 组件把这套 runtime 暴露给 builders 和团队
## Runtime 名词
- `downcity`:整体 runtime 与 CLI 表面
- `console`:全局控制面
- `agent`:一个项目 runtime
- `session`:一条执行线程
- `power`:一个能力边界
- `city`:当前 Downcity runtime 的 package / runtime 名称
## 最短模型
- 产品负责体验
- agent 负责运行工作
- session 负责执行轮次
- Powers 负责暴露能力
- City 负责把重复后端能力从每个产品里抽出来
## Power 的两类
### 本地 Power
- 直接挂到一个 SDK `Agent`
- 通常是 action-first 或 hook-oriented
### 托管 Power
- 归属于目标 agent runtime
- 可以持有 lifecycle-owned runtime state
## 为什么这很重要
读当前代码或文档时,优先看产品边界:
- 需要产品操作,看 `downcity`、`console` 和产品文档
- 需要执行拥有者,看 `agent` 与 `session`
- 需要找能力拥有者,看 `power`
- 需要可复用后端能力,看 City SDK 和 services 文档
### 安全与权限总览
Source: https://downcity.ai/zh/docs/security/overview/
Downcity 的安全模型分为 3 层:
1. Agent 隔离:一个运行中的 agent 绑定一个 `projectRoot`,默认不会跨项目串上下文。
2. Chat Access:按 Agent、Chat 账号和平台用户身份决定消息能否进入 Agent。
3. 本地配置:模型、Workspace、Agent 和 Power 配置保存在本机文件中,主要依靠文件权限隔离。
## 你需要重点关注
1. 不要在终端残留错误的 `DC_CITY_HOST/DC_CITY_PORT`,避免把命令路由到错误 runtime。
2. 新用户默认不会进入 Agent,启用聊天渠道后先处理待批准的 Access Request。
3. 本地配置以明文保存,不要把 `~/.downcity`、Agent 配置或 Power 配置复制到不受信任的位置。
## 推荐阅读顺序
1. [API 认证](/zh/docs/security/api-authentication)
2. [权限配置](/zh/docs/security/permissions)
3. [数据存储与权限](/zh/docs/security/data-security)
4. [调用路由与环境隔离](/zh/docs/concepts/invocation-routing-and-isolation)
### API 认证
Source: https://downcity.ai/zh/docs/security/api-authentication/
Downcity 现在只保留一种对外认证模型:
- 不再提供用户名 + 密码登录
- 不再提供浏览器 session
- 统一使用 Bearer Token
- token 只由本机终端里的 CLI 管理
当前已经落地的接口是:
- `GET /api/auth/status`
- `GET /api/auth/me`
- `GET /api/auth/token/list`
- `POST /api/auth/token/create`
- `POST /api/auth/token/delete`
## Token 归属
Token 由拥有本地 runtime 的同一台机器创建和保存。Token 名称只用于人工识别和管理,不代表不同权限或不同渠道类型。
## 用 Token 调用接口
拿到 token 后,统一放进 `Authorization` 头:
如果 token 有效,接口会返回当前主体、角色和权限。
## 管理 Token
Token 创建、列出和删除都是本机操作。HTTP token 管理接口需要先持有 Bearer Token,所以外部调用者不能在没有权限的情况下静默签发新权限。
## `DC_AUTH_TOKEN`
先说结论:
- 只有对外 HTTP 调用才需要 Bearer Token
- 本地托管命令现在默认也是通过本地 agent daemon 的 HTTP 端点访问
对于直接调用 API 的用户,只需要记住一条:
- 对外调用统一使用 `Authorization: Bearer `
但在 CLI/runtime 里,真正需要关心的环境变量只有一个:
| 变量 | 面向谁 | 作用 |
| --- | --- | --- |
| `DC_AUTH_TOKEN` | 用户、脚本、CI | 显式指定“这次 CLI/API 调用用哪个 token” |
只有当前 HTTP 端点需要鉴权时,CLI 才会进入下面的 token 解析顺序:
1. `--token`
2. `DC_AUTH_TOKEN`
所以:
- `DC_AUTH_TOKEN` 是显式覆盖
- 受保护的 HTTP 调用最终会把 `DC_AUTH_TOKEN` 归一化为 Bearer Token 请求头
如果你看到 `Missing bearer token`:
- 不要先怀疑 `sessionId`
- 先确认目标 HTTP 端点当前是不是启用了鉴权
- 再检查当前进程有没有可用 token 来源
- 如果是在 agent/Bash/ACP 子进程里,先确认这条命令是否需要显式传入 token
具体 token 工作流放在 [Downcity CLI 产品文档](/zh/docs/reference/cli)。
## 当前边界
当前模型就是 token-only:
- 已移除用户名 + 密码登录
- 已移除 `bootstrap-admin`
- 已移除 `login`
- 已移除 `password/update`
- Console UI、Chrome Extension、外部脚本统一使用显式签发的 Bearer Token
后续如果增加新的接入面,也会继续沿用这套 Bearer Token 模型,而不是再引入单独的登录 session。
### Chat Access
Source: https://downcity.ai/zh/docs/security/permissions/
Chat Access 是 Messenger Power 的准入能力,只回答一个问题:某个外部 Chat 用户的私聊或群聊消息,是否可以进入指定 Agent。
它不是 Agent Core 权限,也不是通用 Role/Permission 系统。用户通过准入后,Messenger Power 只把消息交给 Agent,不会向 Session 或其他 Power 传播角色和权限。
## 核心规则
- 默认拒绝:没有明确 `allow` Grant 时,消息不会进入 Agent。
- 按 Agent 隔离:访问 Agent A 的批准不会自动允许访问 Agent B。
- 按 Chat 账号隔离:身份键由 `channel + issuer + subject_id` 组成,同一用户通过两个 Bot 访问时是两个 Principal。
- 范围明确:持久化 Grant 只有 `direct` 和 `group`;管理命令中的 `all` 会展开为两条 Grant。
- `deny` 和 `revoke` 不同:拒绝后不再生成请求;撤销后用户可以再次请求。
## 批准流程
未批准用户发送私聊消息时,Messenger Power 会创建或复用一个 pending Request,并返回类似命令:
```bash
city power action chat access-approve assistant --input '{"request_id":"req_xxx"}' --token
```
管理员也可以先查看请求:
```bash
city power action chat access-snapshot assistant --input '{}' --token
```
群聊中的未批准消息默认静默拒绝,pending Request 仍会进入同一个管理列表。
## 数据保存位置
每个 Agent 独立保存:
```text
~/.downcity/agents//powers/messenger/messenger/access.db
```
Chat Access 数据库包含 Principal、Request、Allow/Deny Grant 和审计事件,不包含 Bot Token、App Secret 或消息正文。Messenger 渠道凭据属于 `~/.downcity/powers/messenger/config.toml` 中的唯一明文配置;Messenger Config UI 读取时不会返回凭据原文。
## 与 City Auth 的区别
Agent Token 负责 HTTP 控制面认证。一个 Token 只属于一个 Agent,并拥有该 Agent API 的完整能力,不包含 User、Role 或 Permission。Chat Access 负责外部 Telegram、飞书或 QQ 用户能否向某个 Agent 发送消息。两者数据、身份和判定路径互不混用。
## 相关文档
- [CLI 命令](/zh/docs/cli/commands)
- [Messenger Power](/zh/docs/power/messenger)
- [`.downcity` 目录](/zh/docs/reference/dot-downcity)
### 数据存储与权限
Source: https://downcity.ai/zh/docs/security/data-security/
本地 CLI 与 Desktop 状态采用明文 SQLite/JSON 文件存储:
1. Workspace 索引、平台设置与本地 Token:`~/.downcity/downcity.db`
2. Agent 定义与主体指令:`~/.downcity/agents//agent.json`、`SOUL.md`
3. Power 定义与唯一配置:`~/.downcity/powers//power.json`、`config.toml`
## 文件权限与边界
数据库文件、Agent 配置目录与 Power 配置文件默认限制为当前用户可读写(数据库和配置文件为 `0600`,目录为 `0700`)。这不是加密存储;能够读取本机用户文件的进程也能够读取这些配置。
`city env` 是显式的文件配置能力:Global Env 以明文写入 `~/.downcity/.env`,Workspace Env
以明文写入项目 `.env`。Global 文件权限限制为仅当前用户可读写;项目 `.env` 默认加入
`.gitignore`。如需更强的密钥保护,应通过启动进程或密钥管理系统注入环境变量。
## 用户侧最佳实践
1. 不要提交 `~/.downcity` 或任何本地配置文件到版本库。
2. 不要提交 Workspace `.env`,并限制 `~/.downcity/.env` 的文件访问权限。
3. 需要更强的密钥保护时,通过启动进程或操作系统密钥管理系统注入环境变量。
## 相关文档
- [Model 绑定](/zh/docs/configuration/model)
- [Model 配置](/zh/docs/configuration/model)
### CLI
Source: https://downcity.ai/zh/docs/cli/overview/
Downcity 安装包提供两个独立工具:
- `city`(别名 `downcity`)是本机 City 容器,管理 Agent、Power、环境变量和 Federation 连接。
- `fed`(别名 `downfed`)是 Federation Server Manager;TUI 管理已注册 Server,`fed web` 启动本地 Web 控制面,项目命令读取当前目录的 `federation.json`。
## City 数据模型
Agent 与 Workspace 是两个独立的全局实体。Agent 由 `agent_id` 标识,结构化定义位于 `~/.downcity/agents//agent.json`,稳定主体指令位于同目录的 `SOUL.md`。Workspace 由 `workspace_id` 标识并指向项目目录。一个 Agent 可以进入多个 Workspace,双方都不保存持久化绑定。CLI 与 Desktop 分别拥有独立的 City,但共享同一套用户级文件和 Workspace 索引。
Federation Admin 配置与 Embassy User Session 也统一保存在 `~/.downcity/downcity.db`。升级后,旧 `~/.downcity/federation.db` 中的管理配置会被一次性迁入;旧文件保留用于人工恢复,但运行时不再读取。
每个 Power 使用稳定 ID 对应 `~/.downcity/powers//`,唯一明文配置位于 `config.toml`。第三方 Power 的安装目录包含唯一 `power.json`、建立 ESM 边界的 `package.json`,以及清单声明的可选 `main`、`renderer` 自包含入口,不包含源码和构建配置。City 会把已登记的 Power 和同一份配置提供给全部 Agent;`agent.json` 不保存 Power 引用。`downcity.db` 只保存 Workspace 索引、Token 和平台状态,不保存 Agent 或 Power 配置。
## City 命令
| 命令 | 说明 |
|---|---|
| `city agent` | 管理全局 Agent |
| `city power` | 管理 Power 制品、唯一配置与 Action |
| `city env` | 管理 City 注入的环境变量 |
| `city federation` | 管理 Federation 连接 |
自动化脚本可以用 `DOWNCITY_FEDERATION_URL` 和 `DOWNCITY_USER_TOKEN` 覆盖当前 Embassy 用户身份。旧的 `DOWNCITY_CITY_URL`、`DOWNCITY_CITY_USER_TOKEN`、`CITY_URL` 与 `CITY_USER_TOKEN` 不再解析。
`city init`、`city update` 和 `city config` 不再提供。全局更新使用 `npm i -g downcity@latest`。
所有操作单个 Agent 的命令都以 `agent_id` 为目标。TTY 中省略 ID 时打开全局 Agent 选择器;非交互环境会返回 `Agent ID is required`,不会根据当前目录推断。
## 平台支持
CLI 架构天然跨平台。macOS 与 Linux 是稳定支持目标;Windows Adapter 当前为 preview。
继续阅读:[CLI 命令](/zh/docs/cli/commands)。
### CLI 命令
Source: https://downcity.ai/zh/docs/cli/commands/
## City 生命周期
```bash
city on # 启动唯一 CLI City daemon
city on --port 5314 --rpc-port 15314
city status
city restart
city off
city reset # 确认后重置本地配置数据库
city reset --yes # 跳过确认
```
CLI City 使用一个 HTTP 端口和一个原生 TCP RPC 端口,并在两个协议内按 `agent_id` 路由全部 Agent。
`city reset` 会先停止 CLI City daemon,再删除 `downcity.db`、`downcity.db-wal` 和 `downcity.db-shm`。它不会删除 `.env`、Agent/Power 配置或 Workspace 中的 Session 文件。
## Agent
```bash
city # 直接打开 Agent 列表
city agent # 打开同一个 Agent 列表
city agent create [path] # 不传路径:当前目录或系统文件夹选择窗口
city agent list
city agent model [agent_id]
city agent chat [agent_id]
```
裸 `city` 命令直接打开 Agent 列表,不展示 Agent runtime 状态。`city agent list` 以 `名称 · agent_id` 展示每个 Agent。Federation、全局 Powers、语言和命令帮助直接位于同一列表末尾的“设置”分组。
City 装配的 Agent 默认启用 `ask_question` Tool。在
`city agent` 面板选择 Agent 后会直接进入最近一次对话,并跟随该 Session 所属 Workspace;该 Agent 还没有历史时,在入口 Workspace 新建 Session。模型需要关键补充信息时会显示文本、单选或多选面板;CLI 将完整答案
提交给当前 Session,并继续同一个 Turn。按 Esc 可停止当前 Turn。
Agent 与 Workspace 独立登记。Agent 每次执行时进入所选 Workspace,不保存持久化 Workspace 绑定。Agent 没有独立启动状态;`city agent chat --session-id ` 必须指向已存在的 Session,并进入该 Session 所属 Workspace;`--workspace ` 会把 Chat 限定在该 Workspace:只在其中查找 Session、必须与所选 `--session-id` 的 Workspace 一致,新 Session 也会创建在那里。Chat 内使用 `/new` 创建新 Session、`/session` 选择已有 Session 或新建 Session、`/config` 打开 Agent 配置,配置完成后会回到原对话。City 运行时 Chat 走 RPC,脚本化的一次性调用在 City 关闭时仍可使用本地装配路径。
在输入框中可以使用 slash command 打开系统文件选择器并添加本地附件:
```text
/attach
```
附件会加入下一条消息;也可以使用 `/file` 别名。Unix 终端的 `Ctrl-V`(Windows 为 `Alt-V`)会优先尝试粘贴剪贴板中的图片或文件,无法识别时仍按普通文本粘贴处理。
审批请求在专用面板中完成,不再使用 `/approve` 或 `/deny` slash 命令。
## City Token
Token 由 City 管理,授权访问指定的 Agents,或明确允许全部现有及未来 Agents。`city` 就是命令入口,`downcity` 是别名,不需要 `downcity city`。
```bash
city token # 交互式创建、查看、修改和撤销
city token create # 按步骤填写并确认
city token list
city token create --name chrome --agents researcher,programmer --never-expires
city token create --name automation --all-agents --expires-at 2027-01-01T00:00:00Z
city token update --agents researcher
city token update --expires-at 2027-02-01T00:00:00Z
city token delete
```
创建时名称、授权范围和有效期必须明确:不完整参数在终端中交互补齐,在脚本或管道中报错。交互支持 Agent 多选、30/90 天、永不过期及自定义日期,提交前显示配置供确认。`update` 或 `delete` 不传 ID 时交互选择;参数完整时直接执行。命令支持 `--json`。
明文只在创建成功时显示一次,列表和详情只含摘要。把明文填入 Chrome 的访问凭据,不要发送到聊天。丢失后重新签发。删除或缩小授权范围会拒绝后续访问,并在约一秒的权限复查周期内关闭受影响 HTTP 长连接;取消不能撤销已经发生的外部副作用。
旧的 `city agent token` 命令和 Agent Token 不再用于认证,请通过 `city token create` 重新签发。已有旧表不自动迁移或扩权。凭据管理只通过本机 CLI;连接 Token 不自动获得创建或撤销其他 Token 的权限。
## Power
`city power list` 会展示每个 Power 的标题、稳定 ID、用途、来源和是否提供 Config。
```bash
city power list
city power install
city power update
city power uninstall
city power inspect
city power config
city power config --set '{"key":"value"}'
city power action [agent_id] --input '{"key":"value"}' --token
```
一个第三方来源目录只定义一个 Power。`power.json` 声明 `schema_version: 1`、全局唯一 `id`、版本、必填的 `.md` `readme` 相对路径,以及可选的 `main`、`renderer`。`main` 是自包含的统一 City Power ESM;`renderer` 对象声明单文件 ESM 入口以及 `sidebar`、`mainview`、`config` 三项静态能力,Sidebar 与 Mainview 必须成对出现。两个运行入口至少提供一个。`package.json` 必须声明 `"type": "module"`。安装目录只保留声明制品、文档、可选图标与本地 `config.toml`;安装过程不会执行依赖安装、构建脚本或任何第三方入口。
声明 Config 的 Power 在 `powers//config.toml` 中保存唯一配置。具体结构、校验、Secret
投影和 Config UI 都由 Power 自己管理。CLI 的 `config --set` 仅用于显式替换完整 JSON,不生成
通用表单,也不会输出凭据原文;无 `--set` 时只显示是否已配置和字段名。
City 自动把全部已注册 Power 提供给全部 Agent;`agent.json` 不保存 Power 引用或配置。需要多账号
时,由 Power 在自己的配置结构中表达账号集合和选择规则。配置保存后,Power 负责刷新受影响的长期资源。
CLI、Agent 的 Power 工具、HTTP 与定时任务统一调用 Power Action。不存在 `power command`,Chat 也不再是顶层命令特例。
## Env 与 Federation
```bash
city env # 打开 Env TUI
city env list --global
city env list --agent
city env set --global
city env set --agent
city env delete --global
city env delete --agent
city federation
```
Global Env 写入 `~/.downcity/.env`;Workspace Env 写入项目根目录的 `.env`。也可以从
`city agent` 的“配置 Env”入口选择 Agent 所使用的 Workspace。修改完成后,CLI City 会更新受影响的 runtime
Agent,并通过 Workspace 将新快照广播给已有 Session;未被该 City 持有的 Agent
会在下一次被 City 装配时读取新配置。
多个 Agent 在同一 Workspace 运行时共享同一个项目 `.env`。运行时优先级为:Global `.env`
低于 Workspace `.env`,启动进程显式环境变量最高。`list` 不显示明文值;`copy` 只允许从
本机 CLI 显式执行。
Federation 用户请求统一通过 Embassy。交互登录态保存在 `~/.downcity/downcity.db`;自动化脚本可以用
`DOWNCITY_FEDERATION_URL` 与 `DOWNCITY_USER_TOKEN` 显式覆盖当前登录态。这两个身份变量不会从
Global `.env` 注入 Workspace 进程。
## Fed
```bash
fed # Federation Server 管理 TUI
fed web # 启动当前 Federation 的本地 Web 管理 UI
fed web --no-open --port 43128 # 启动但不自动打开浏览器
fed server add
fed server manage
fed bureau token # 打开 Bureau Token 管理 TUI
fed bureau token issue # 输入用途并请求 Federation 签发 Token
fed bureau token list
fed bureau token revoke
fed query GET /health
fed create [dir]
fed deploy [source]
```
`fed web` 默认在 `http://127.0.0.1:43128` 启动本地控制面。它读取当前 active
Federation,也可以用 `--federation ` 显式选择。管理员会话 Token 只由本地 CLI
进程持有,不会发送到浏览器;为避免把 Federation 管理控制面暴露到网络,该命令只允许监听
`127.0.0.1`、`localhost` 或 `::1`。页面包含 Dashboard、用户、Bureau、模型、Env、
Credits、Usage、支付、Service 目录与调试器。分析区进一步分为数据概览、用户活跃、
Usage 消耗、用户留存、调用质量和用户明细。
Usage Dashboard 以浏览器所在 IANA 时区统计:日活是今日产生至少一次 AI Usage 的去重
用户,周活和月活分别是滚动 7 天与 30 天去重用户。用户用量页展示所有注册用户,包括
零用量用户;默认按 Total Tokens 降序排列,与总览的 Token 消耗排行使用相同口径,也可按
调用数、Credits、P95 耗时与最后活跃时间排序。每个用户同时展示 Input、
Output、Total Token、成功率、Top Model、平均/P95 耗时和已入账 Credits 消耗。图表包含
DAU/WAU/MAU 折线、24 小时活跃柱状图、Token 堆叠图、模型与 Action 分布、执行结果与
耗时趋势,以及 D1/D3/D7/D14/D30 Cohort 留存表和漏斗。
### 项目初始化
Source: https://downcity.ai/zh/docs/cli/init/
`city agent create` 会独立登记 Workspace,并创建 Agent 的用户级 `agent.json` 与 `SOUL.md`。它不会在 Workspace 中生成 Agent 声明,也不会保存 Agent-Workspace 绑定。
```bash
city agent create
```
不传路径时,交互流程可以直接使用当前目录,或者打开操作系统原生文件夹选择窗口。传入相对路径或绝对路径则直接使用该路径,不进入目录选择流程:
```bash
city agent create .
city agent create ./my-project
city agent create /absolute/path/to/my-project
```
生成结构:
```text
my-project/
├── .env
├── .agents/
│ └── skills/
└── .downcity/
```
Agent 定义写入 `~/.downcity/agents//agent.json`,主体指令写入同目录的 `SOUL.md`;Workspace 登记仍保存在 `downcity.db`。项目 `.env` 会覆盖 `~/.downcity/.env` 中的 Global Env;启动进程显式环境变量拥有最高优先级。
```bash
# 允许覆盖现有 Agent 文件定义
city agent create ./my-project --force
```
初始化后,可在 `.agents/skills` 添加项目 Skills,通过 `city agent list` 查看生成的 `agent_id`,直接运行 `city agent chat `,或执行 `city on` 启动长期 City 宿主。
继续阅读:
- [项目结构](/zh/docs/cli/project)
### 项目结构
Source: https://downcity.ai/zh/docs/cli/project/
Agent 定义、Power 配置与运行数据都使用用户级文件。项目目录只作为 Workspace,保留项目环境、Skills 和业务源码。
## 目录布局
```text
my-project/
├── .env # 项目环境变量
├── .agents/
│ └── skills/ # 用户管理、可进入版本控制的 Skills
└── src/ # 业务代码(可选)
```
## 全局配置
用户级配置结构为:
```text
~/.downcity/
├── agents//
│ ├── agent.json
│ ├── SOUL.md
│ └── sessions/
│ ├── /
│ ├── archived-sessions/
│ └── logs/
├── powers//config.toml
├── runtimes/city/
└── downcity.db
```
`downcity.db` 保存 Workspace ID 与路径、Token 和平台状态,不保存 Agent 定义、Power 配置或 Agent-Workspace 绑定。CLI 与 Desktop 读取同一套文件与数据库。
`agent_id` 与 `workspace_id` 是彼此独立的全局身份。Session 只在一次具体执行时接收 Workspace。CLI City 的 daemon 状态记录在 `~/.downcity/runtimes/city/`;Session 保存在 `~/.downcity/agents//sessions/` 下。只有传入 Workspace 时,Session 的 `meta.json` 才记录 `workspace_id`。
## 项目资产
Global Env 单独保存在 `~/.downcity/.env`。项目 `.env` 覆盖 Global Env,启动进程显式环境变量拥有最高优先级;项目 `.env` 默认加入 `.gitignore`。`.agents/skills` 是项目中唯一由用户管理并进入版本控制的 Agent 能力资产。
## 运行数据
Downcity 不再创建 `/.downcity/`。Agent 运行数据统一保存在 City 提供的 `agents//` 存储作用域;没有 City 时保存在内存中。Power 自有数据使用 PowerContext 提供的 Agent 级存储作用域。
继续阅读:
- [项目初始化](/zh/docs/cli/init)
- [Agent SDK](/zh/docs/agent/sdk)
### Agent CLI
Source: https://downcity.ai/zh/docs/cli/cli/
`city` CLI 是本地 City 宿主。它读取 Agent 与 Power 配置,分别创建 Agent、Workspace 和唯一 Power 实例,再由 City 统一管理组合关系与长期运行生命周期。
## 项目初始化
```bash
city agent create
```
不传路径时,可以选择当前目录或通过系统原生窗口选择其他文件夹;也可以显式传入相对路径或绝对路径,例如 `city agent create .`、`city agent create ./my-project`。
这会生成:
- `.agents/skills/`
- `.downcity/`
- `.env`
- CLI 全局 DB 中的 Agent 配置
## 项目目录结构
```text
my-project/
├── .env
├── .agents/skills/
├── .downcity/ # 仅运行数据
└── 你的业务代码
```
## 关键命令
- `city` — 直接打开 Agent 列表;Federation 与全局 Power 操作位于同页的“设置”分组
- `city on` — 启动 CLI City,并恢复全部已绑定 Agent
- `city status` — 查看 City、HTTP/RPC 端口与已配置 Agent
- `city restart` — 重启 City 并重新加载配置
- `city off` — 停止 CLI City
- `city agent chat [agent_id]` — 与 Agent 对话;City 未开启时也可使用
## Chat TUI 安全策略
从 Agent 列表直接进入最近对话后,使用 `/security` 打开当前 Session 的安全策略选择器:
- `Default` — 对应 Session 的 `ask` 模式;每次 host Shell 操作都需要明确审批。
- `Always Allow` — 对应 `always-allow` 模式;当前 Session 后续的 host Shell 操作会自动批准。
当前策略展示在 Chat Header 中。策略只影响当前 Session 的后续 Shell 请求;已经等待审批的请求仍需单独处理。`Always Allow` 不会绕过危险命令拒绝、Workspace 边界或其他运行时安全检查。
## CLI 做了什么
1. 从 CLI 全局 DB 读取 Agent 配置
2. 通过已连接的 Federation AIService 解析 `execution.modelId`
3. 将解析后的配置传给 `new Agent(...)`
4. CLI 从 Repository 读取配置并显式创建 Agent,再注册到 `new City(agents)`
5. 在一个 HTTP 端口和一个 RPC 端口上按 `agent_id` 暴露 Agent
## CLI vs SDK
CLI 用于日常项目管理。SDK 用于将 Agent 能力嵌入自己的应用。两者使用相同的执行核心。
继续阅读:
- [Agent SDK](/zh/docs/agent/sdk)
- [Agent 生命周期](/zh/docs/agent/lifecycle)
### CLI 命令
Source: https://downcity.ai/zh/docs/reference/cli/
Downcity docs 解释设计模型、运行边界和配置语义。
`downcity` / `city` 是基于 `City()` 与 Agent SDK 的本机 City 容器,负责全局 Agent 管理、运行控制与控制台。`fed` / `downfed` 是 Federation Server Manager,TUI 管理已注册 Server,项目命令读取当前目录的 `federation.json`;精确参数以本机安装的 CLI help 为准。
- [CLI:fed 与 city](/zh/city-sdk-docs/packages/cli)
## 本地帮助
精确参数以本机安装的 CLI help 为准。产品文档负责说明命令组和常见工作流。
## 交互式 TUI
不带子命令运行 `city` 或 `downcity` 时,现在默认进入交互式 TUI。`city` 侧聚焦本机 Agent 与 Federation 连接状态;`fed` 侧聚焦 Federation 管理与部署。两个 CLI 都保持同一套稳定布局:左侧 sidebar 承载菜单层级,顶部显示 breadcrumb,loading、输入表单、表格、JSON、消息与选择详情都在右侧 section 内完成,直到你主动退出 TUI。
## CLI 语言切换
`city` 和 `downcity` 现在都支持中文与英文。
- 通过 `--lang zh` 或 `--lang en` 显式切换当前命令语言。
- 如果没有传 `--lang`,CLI 会优先读取最近一次在交互菜单中保存的默认语言,然后再依次读取 `DC_CLI_LANG`、`LC_ALL`、`LC_MESSAGES`、`LANG`。
- 在交互模式下,`city` 和 `downcity` 都可以直接从菜单切换语言,并把该选择持久化到本地。
示例:
```bash
downcity --lang en --help
city --lang zh
DC_CLI_LANG=zh downcity agent --help
```
### .downcity 目录
Source: https://downcity.ai/zh/docs/reference/dot-downcity/
`~/.downcity/` 是 Downcity 的用户级配置与运行数据目录。Downcity 不会在每个项目中创建 `.downcity/` 目录。
## 常见结构
```text
~/.downcity/
├── agents//
│ ├── agent.json
│ ├── SOUL.md
│ ├── sessions///
│ ├── archived-sessions///
│ └── logs/
├── powers//
│ └── config.toml
├── runtimes/city/
└── downcity.db
```
## 重点目录
### `agents//logs/`
每日 JSONL 日志:`.jsonl`。
### `agents//sessions///messages/`
会话消息事实源:`active.jsonl`、`agent_message.json` 与按 sequence 命名的 `segments/*.jsonl`。
`category` 只有 `chat` 与 `work` 两个值,是唯一进入目录名的字段;角色 `type` 记录在
Session 元数据里。
### Memory
长期 Memory 不属于 Workspace `.downcity/` 协议。Agent 私有内容位于 `agents//powers/memory/`;City 共享的 User/Workspace 内容位于 `memory/`,并由 `MemoryPower` 按 Subject 隔离。City 只提供共享根路径,不实现 Memory 数据逻辑。
### Power 运行数据
Power 自有运行数据使用 PowerContext 提供的 Agent 级存储作用域。Power 定义与唯一配置保存在 `~/.downcity/powers//`。
### Skills 目录位置
`SkillPower` 默认扫描 `.agents/skills`(项目级)。如果在构造函数中启用 `use: ["home"]`,也会扫描 `~/.agents/skills`(用户级)。
## 安全建议
- `~/.downcity/` 可能包含敏感配置、对话、Token 与执行痕迹。
- 不要把该目录提交到版本库或复制到不受信任的位置。
### HTTP API 参考
Source: https://downcity.ai/zh/docs/reference/api/
> 面向用户的 Agent API。更多细粒度命令建议优先使用 CLI(`downcity ...`)。
## 基础接口
### `GET /health`
```json
{ "status": "ok", "timestamp": "2026-03-08T12:00:00.000Z" }
```
### `GET /api/status`
```json
{ "name": "downcity", "status": "running", "timestamp": "..." }
```
### `POST /api/execute`
请求体:
```json
{
"instructions": "帮我总结今天的改动",
"chatId": "default"
}
```
## 托管 Power API
### `GET /api/powers/list`
列出托管 Power 运行状态。
## Power API
### `GET /api/powers/catalog`
列出已注册的 Power。
### `POST /api/powers/availability`
```json
{ "power_name": "sound" }
```
### `POST /api/powers/action`
```json
{
"power_name": "sound",
"action_name": "models",
"payload": { "capability": "asr" }
}
```
## Console UI API(摘要)
### `GET /api/ui/powers`
返回 Power 摘要与可用性信息(适用于 Console UI 面板)。
返回字段示例:
```json
{
"success": true,
"powers": [
{
"name": "sound",
"actions": ["models", "asr", "tts"],
"pipelines": ["chat.augmentInbound"],
"guards": [],
"effects": [],
"resolves": [],
"hasSystem": true,
"hasAvailability": true,
"availability": {
"enabled": true,
"available": true,
"reasons": []
},
"config": {
"actions": [
{
"name": "models",
"supportsCommand": false,
"supportsApi": false,
"apiMethod": "",
"apiPath": ""
}
]
}
}
]
}
```
### `POST /api/dashboard/sessions/:sessionId/execute`
在指定会话执行一轮(例如你在 UI 里选中的 chatKey)。
带附件请求示例:
```json
{
"instructions": "请阅读附件 markdown,并给出可执行摘要。",
"attachments": [
{
"type": "document",
"fileName": "page.md",
"contentType": "text/markdown; charset=utf-8",
"content": "# Captured Web Page\n\n..."
}
]
}
```
说明:
- `sessionId` 应传 runtime 返回的目标会话 ID。聊天场景下该 ID 由 chat Power 维护映射(不再要求可由平台字段直接拼接)。
- 附件会先被 runtime 落盘,再以 `` 标签自动注入到用户消息前部。
- 当 `sessionId` 映射到 chat 会话时,会复用 chat Power 的同一条执行队列(与平台入站消息一致),并在执行前写入 chat history。
- chat 上下文调用会立即返回排队结果(`queued=true`、`queueItemId`、`queuePosition`)。
## Action 路由
Power Action 使用这些入口:
- `/api/powers/action`
除 `GET /health` 外,Agent HTTP API 需要该 Agent 自己的 Bearer Token。Token 不包含 User、Role 或 Permission;它只允许访问所属 Agent。
## 调试建议
先确认 runtime 已启动。启动流程见 [Agent start 工作流](/zh/docs/reference/cli);health endpoint 属于已运行的 Agent HTTP 表层。
### 贡献指南
Source: https://downcity.ai/zh/docs/community/contributing/
感谢您有兴趣为 Downcity 做贡献!本指南将帮助您开始。
## 贡献方式
### 🐛 报告错误
发现了 bug?报告它:
1. 先搜索现有问题
2. 创建 bug 报告,包含:
- 清晰的标题和描述
- 重现步骤
- 预期 vs 实际行为
- 环境详情(操作系统、Node 版本等)
- 相关截图(如适用)
### 💡 建议功能
有好主意?提议它:
1. 检查路线图和现有讨论
2. 创建功能请求,包含:
- 问题陈述
- 建议的解决方案
- 用例
- 潜在的实现
### 📖 改进文档
文档也是代码!您可以:
- 修正拼写和语法
- 添加示例
- 澄清混淆的部分
- 翻译成其他语言
- 添加新指南
### 🔧 代码贡献
准备编码?太好了!
1. 从标记为 `good-first-issue` 的问题开始
2. 遵循下面的贡献指南
3. 提交拉取请求
---
## 开发设置
### 前提条件
- Node.js >= 18
- pnpm(推荐)或 npm
- Git
### Fork 和克隆
```bash
# 在 GitHub 上 fork 仓库
# 克隆您的 fork
git clone https://github.com/YOUR_USERNAME/downcity.git
cd downcity
# 添加 upstream 远程
git remote add upstream https://github.com/genesiscosmos/downcity.git
```
### 安装依赖
```bash
# 安装依赖
pnpm install
# 设置开发环境
pnpm dev:setup
```
### 构建和测试
```bash
# 构建项目
pnpm build
# 运行测试
pnpm test
# 运行 linter
pnpm lint
# 类型检查
pnpm typecheck
```
---
## 项目结构
```
downcity/
├── packages/
│ ├── cli/ # CLI 工具
│ ├── core/ # 核心 agent 逻辑
│ ├── llm/ # LLM 提供商实现
│ └── tools/ # 内置工具
├── private/apps/homepage/ # 文档站点
└── examples/ # 示例配置
```
---
## 进行更改
### 分支策略
```bash
# 创建功能分支
git checkout -b feature/my-feature
# 或修复 bug
git checkout -b fix/issue-123
# 或文档
git checkout -b docs/update-guide
```
### 提交消息
遵循约定式提交:
```
feat: 添加对自定义工具的支持
fix: 解决任务调度器中的竞态条件
docs: 更新安装指南
chore: 升级依赖
test: 为权限系统添加测试
refactor: 改进 LLM 提供商抽象
```
### 代码风格
遵循项目的代码风格:
```bash
# 运行格式化工具
pnpm format
# 运行 linter
pnpm lint:fix
# 类型检查
pnpm typecheck
```
---
## 拉取请求流程
### 提交之前
1. **运行测试**:`pnpm test`
2. **格式化代码**:`pnpm format`
3. **检查类型**:`pnpm typecheck`
4. **更新文档**:如适用
5. **添加测试**:针对新功能
### 提交 PR
1. 推送到您的 fork
2. 创建拉取请求到 `genesiscosmos/downcity:main`
3. 填写 PR 模板:
- 更改描述
- 相关问题
- 测试步骤
- 破坏性更改(如有)
### 审查流程
维护者将审查您的 PR 并提供反馈。解决审查意见并更新您的分支。
### 合并
一旦批准,您的 PR 将被合并。维护者将:
- 如需要则压缩提交
- 更新更新日志
- 部署到生产环境
---
## 编码标准
### TypeScript
- 使用严格模式
- 避免 `any` 类型
- 优先使用 `const`/`let` 而不是 `var`
- 回调使用箭头函数
- 为公共 API 编写文档
- 在 `cli/city/src` 内可使用 `@/` 路径别名(映射到 `src`),`pnpm build` 会自动回写为 `bin` 中可运行的相对路径
### 测试
- 为新功能编写单元测试
- 目标是 >80% 的代码覆盖率
- 模拟外部依赖
- 测试边缘情况
### 文档
- 为公共 API 编写文档
- 添加 JSDoc 注释
- 包含使用示例
- 如果行为更改,更新指南
---
## 开发工作流程
### 功能开发示例
```bash
# 1. 创建分支
git checkout -b feature/add-discord-power
# 2. 进行更改
# ... 编辑文件 ...
# 3. 提交更改
git add .
git commit -m "feat: 添加 Discord bot 集成"
# 4. 测试
pnpm test
# 5. 推送到 fork
git push origin feature/add-discord-power
# 6. 在 GitHub 上创建 PR
# 访问您的 fork 并创建拉取请求
```
### 与上游同步
保持您的 fork 最新:
```bash
# 获取上游更新
git fetch upstream
# 合并上游 main
git checkout main
git merge upstream/main
# 推送到您的 fork
git push origin main
```
---
## 社区准则
### 行为准则
- 保持尊重和包容
- 提供建设性反馈
- 优雅地接受反馈
- 关注什么对社区最有利
### 获取帮助
- 在 Discord 中提问
- 为复杂主题开始讨论
- 尽早请求审查(WIP PR)
---
## 开发者起源证书
通过贡献,您同意 DCO:
```
开发者起源证书
版本 1.1
1. 开发者和利益相关者通过贡献和补丁与项目的合作
是项目的预期合作和协作方法。
2. 开发者保证:
(a) 贡献全部或部分由我创建,我有权在文件中
指示的开源许可证下提交;或
(b) 贡献基于先前的工作,据我所知,涵盖在适当的
开源许可证下,我有权在该许可证下提交该工作,
包括对其进行的修改,无论是全部或部分由我创建,
与该项目相关的同一开源许可证(除非被允许
在不同许可证下提交),如文件中所示;或
(c) 贡献直接由我提供的其他一些创建、认证 (a)、(b)
或 (c) 且我未修改它的人。
(d) 我理解并同意该项目和贡献是公开的,贡献记录
(包括我提交的所有个人信息,包括我的签名)
将无限期维护,并根据该项目或所涉及的开源许可证
一致地重新分发。
```
添加到您的提交:
```bash
git commit -s -m "feat: 添加新功能"
```
---
## 项目特定指南
### 核心包更改
对于 `packages/agent` 的更改:
- 为新功能添加测试
- 如需要更新架构文档
- 考虑向后兼容性
### CLI 更改
对于 `cli/city` 的更改:
- 更新 CLI 帮助文本
- 添加使用示例
- 为新命令编写文档
### 文档更改
对于 `private/apps/homepage/` 的更改:
- 本地测试:`pnpm dev`
- 检查损坏的链接
- 如需要更新导航
---
## 资源
### 文档
- [架构](/zh/docs/concepts/architecture) - 系统架构
- [Power 命令](/zh/docs/reference/cli) - 用户侧 Power 命令文档
- [API 参考](/zh/docs/reference/api) - Runtime HTTP API 文档
### 社区
- [Discord](https://discord.gg/downcity) - 实时聊天
- [Telegram 社区群组](https://t.me/+iozIHyXr-BJhNjE1) - 问答
- [GitHub Issues](https://github.com/genesiscosmos/downcity/issues) - Bug 报告
---
## 认可
感谢您的贡献!您的帮助让 Downcity 对每个人都更好。
贡献者在以下地方得到认可:
- [贡献者](https://github.com/genesiscosmos/downcity/graphs/contributors)
- 发布说明
- 年度亮点
---
## 下一步
准备贡献?
1. 查找 [good first issues](https://github.com/genesiscosmos/downcity/labels/good-first-issue)
2. 加入 [Discord 社区](https://discord.gg/downcity)
3. 开始编码!🚀
## City SDK
### City SDK
Source: https://downcity.ai/en/city-sdk-docs/
Downcity's City SDK is the way AI builders create, deploy, and call City. A Federation can serve multiple web apps, extensions, desktop tools, client demos, and internal tools without making each product rebuild the same AI backend.
City exposes a stable **product-facing service boundary** instead of a raw provider API. Inside City, capabilities are organized as `Service`, `Action`, and `AIService`.
- the product client holds `user_token` (which contains `bureau_id`)
- it calls `text()`, `stream()`, `image()`, and other services through `Embassy User`
- City keeps the model catalog, provider routing, hooks, usage, and env behind the boundary
The server-side runtime class is `Federation` (also called the Federation here); product clients use the `City` client to reach it. For the boundary between them, see [Federation and City](/en/city-sdk-docs/concepts/federation).
## Pick your reading path
Start Here
I want to run one full call chain
Deploy a Federation, expose one model, issue a token, and make a product client call `text()` or `stream()` successfully.
Mental Model
I want to understand the boundaries first
Separate the Federation, product clients, your business backend, AI gateways, and real providers before implementation details start leaking together.
Real Scenarios
I am connecting a real city now
Read the practical guides for pure-client products, multi-product reuse, auth, provider env, hooks, and business logic.
Packages
I want to read by package
Jump straight into `@downcity/agent`, client, and CLI docs; official service packages live in Federation Services Docs.
API
I only need the interfaces
Jump straight to City, Embassy User, Embassy Admin, HTTP API, and environment variables.
## One diagram for the full model
Many product clients
Web apps, extensions, apps, desktop tools, client demos, and internal tools.
Embassy User + user_token (which contains bureau_id)
→
Federation
Verifies the token, resolves the model catalog, and dispatches the request to AIService, services, or custom Service actions.
Hooks and usage
Quota, balance, billing, logging, and risk control can plug into the Federation while your own business rules stay in your system.
→
Upstream execution
OpenAI, Claude, Gemini, private models, or your own AI Gateway / proxy.
Product clients see stable Service actions and model IDs. Which provider or gateway City uses internally remains a server-side detail.
## What the City SDK owns
- runtime database and Service-owned tables
- provider keys and runtime env
- `user_token` verification and administrator-session management
- model metadata and runtime through `AIChannel.model()` + `AIService.use()`
- public model catalogs through `city.ai.catalog()`
- usage records and hook extension points
## What it does not own
- your user system
- orders, subscriptions, CRM, or content data
- product-specific pages, interaction, and business UX
- a public “unified provider API” for arbitrary third-party systems
More precisely:
> The City SDK owns the Federation and its Service-facing AI infrastructure.
> Your products still own their users, business logic, interface, and pricing model.
## Recommended order
1. First integration: start at [Quickstart](/en/city-sdk-docs/quickstart)
2. Want the full city model: read [Concepts](/en/city-sdk-docs/concepts)
3. Connecting a real business scenario: read [Guides](/en/city-sdk-docs/guides)
4. Want a package-by-package map: read [Packages](/en/city-sdk-docs/packages)
5. Need concrete interfaces: come back to [Reference](/en/city-sdk-docs/reference)
### Federation, Bureau, Embassy, and City
Source: https://downcity.ai/en/city-sdk-docs/concepts/federation/
These four concepts have independent responsibilities:
- `Federation` is the authority backend and trust root. It owns accounts, Bureau records,
Services, model catalogs, usage, env, and Token issuance.
- `Bureau` is a product or business partition inside Federation. A Bureau Token identifies a
trusted product backend.
- `Embassy` is the window through which users and administrators access Federation. It has only
`user` and `admin` identity domains.
- `City` is the application composition root from `@downcity/city`. It owns Workspaces and can hold an Embassy service client. It is not a Federation
client or a registered Federation identity.
```ts
import { Embassy, Federation } from "@downcity/federation";
import { City } from "@downcity/city";
const federation = new Federation({ database });
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
const city = new City({ embassy, workspaces: [workspace] });
const agent = new Agent({ id: "assistant" });
city.agents.add(agent);
```
## Identity boundaries
User Token, Admin Session Token, and Bureau Token are not interchangeable:
- `embassy.user` holds or obtains a User Token.
- `embassy.admin` holds or obtains an Admin Session Token.
- `new Bureau({ bureau_token })` holds a long-lived Bureau machine credential.
- City does not become one of those identities. An Agent running inside City may use an Embassy
or another client as an ordinary dependency.
## CLI mapping
- `fed` / `downfed` deploys and manages Federation.
- `city` / `downcity` manages the local Agent host.
The two commands may share one distribution, but they own different runtimes and lifecycle state.
### Product Boundary Model
Source: https://downcity.ai/en/city-sdk-docs/concepts/product-model/
The core mental model of Downcity is not "one city, one backend." It is:
> one Federation serving many AI products and workflows.
The reason is simple. What gets rebuilt again and again is usually not your business backend, but the AI call layer:
- provider key storage
- model catalog
- token validation
- usage records
- hooks, quotas, billing, and logs
Once you ship a second or third AI product, these capabilities repeat everywhere.
## Many products, one Federation
Product A
Web tools, content products, workflow utilities.
bureau_id = product_a
Product B
Chrome extensions, desktop tools, mobile entry points.
bureau_id = product_b
Product C
Client demos, internal tools, vertical sites.
bureau_id = product_c
→
Federation
City model catalog, provider env, Service routes, token validation, usage, and hooks.
## What should stay in the Federation
- Model catalog and provider configuration
- `user_token` validation
- AI service calls such as `city.ai.stream()` and `city.ai.image_create()` / `city.ai.image_result()`
- Structured decision calls such as `city.ai.decision()`
- Usage records, logs, quotas, and business hooks
- Call logic reused across multiple products
## What should stay in the product or business system
- User registration, login, and account systems
- Orders, subscriptions, balances, and CRM
- Pages, interactions, workflows, and content data
- Each product's own brand, pricing, and go-to-market strategy
In other words, Downcity centralizes the **AI infrastructure** layer, not your whole business system.
## Why this is more stable than "one backend per project"
If every new product copies a separate AI backend, the same issues appear quickly:
- provider keys scattered across many places
- inconsistent token rules
- inconsistent usage and logging conventions
- every new provider, model, or quota strategy requires edits in many repos
Once the AI call layer is centralized into the Federation, a new product usually only needs two steps:
1. create a new `bureau_id`
2. connect `Embassy User` in the product client
## What `bureau_id` means in this model
`bureau_id` is not a model name, and it is not the full abstraction of your tenant system.
It is the runtime's stable primary key for the product boundary. It answers questions like:
- which product started this call
- whether this product is currently `active`
- which product dimension hooks and usage should record against
- whether a user's calls should be separated across different products
If you are building a multi-product portfolio, `bureau_id` is the most stable boundary.
## The role of model at the city layer
In the current implementation, a model is a runtime capability unit registered in Runtime, not something owned by a specific city.
At the product layer, a model is more like a visible capability:
- you can pass it explicitly into `city.ai.stream({ model, call })`
- every AIService call must pass an explicit model id or `CityModel`
This means the product sees stable model IDs, not upstream provider details.
## Model type and modalities
Every catalog entry carries a `model_type`: `language`, `decision`, `image`, `sound`, or `video`. Federation derives it from the capabilities the model actually implements, so it shares a source with `modalities`.
The two answer different questions. `model_type` says what the model is, and is used for display and grouping. `modalities` says what it can do, and is what you read to decide whether a capability is available. A conversation model carries `text` / `stream`; a decision model carries only `decision`, which is why only the former can be passed to an Agent.
### AI Architecture
Source: https://downcity.ai/en/city-sdk-docs/concepts/architecture/
```text
City client / Agent
|
v
CityModel (ModelClient)
|
v
Federation AIService
auth -> model lookup -> fallback -> reasoning -> metering
|
v
AIChannel.stream(AIChannelStreamInput)
|
v
OpenAI-compatible or private Provider Adapter
```
`CityModel` is the client-side executable model. It knows the public `CityModelDescriptor` and the Federation transport, but never knows upstream keys or vendor protocol details.
`AIService` is the server-side orchestrator. It owns route selection, fallback, reasoning validation, usage normalization, and billing.
`AIChannel` is the server-side upstream boundary. It owns connection configuration, upstream model selection, server-side `provider_options`, and delegates conversion into `ModelStreamEvent` to a Provider Adapter.
The OpenAI-compatible endpoint is an adapter, not another execution path:
```text
OpenAI JSON/SSE -> ModelCall -> AIService -> AIChannel.stream -> ModelStreamEvent -> OpenAI JSON/SSE
```
### Token Model
Source: https://downcity.ai/en/city-sdk-docs/concepts/token-model/
Downcity uses an administrator session token to manage Federation, and `user_token` to let end-user clients call services.
Trusted backend
Your backend logs in with an administrator ID and password, then holds a short-lived session token.
Create city / request user_token
→
Downcity
Validates the administrator session token, issues `user_token`, and stores provider keys internally.
→
End-user client
The frontend, extension, or app uses Embassy User with `user_token` and calls services.
No access to administrator credentials
## user_token
`user_token` is the call credential used by the end user or product. Embassy Admin can request it from Federation using `bureau_id + user_id`.
- `bureau_id`
- `user_id`
- `metadata`
- `ttl`
Product configuration supplies `bureau_id` when login starts. After login, service calls only carry
`user_token`; Federation reads `bureau_id` from the verified Token. The client never touches provider
keys or administrator session tokens.
User Tokens use one `downcity:user` audience. Federation and Bureau Servers validate the same audience;
a Bureau Server additionally compares the Token's `bureau_id` with its machine-bound Bureau ID.
`bureau_id` always remains the original product-provided value and is never composed into or normalized
as an audience.
## Administrator session token
Use the administrator ID and password configured by `fed deploy` to sign in through Embassy Admin. The response contains a time-limited administrator session token for creating Bureaus, managing env, and requesting Federation to issue `user_token`.
```ts
const embassy = new Embassy({
federation_url: "https://base.example.com",
admin_token: administrator_session_token,
});
const bureau = await embassy.admin.bureaus.create({
name: "Chrome Extension",
server_url: "https://bureau.example.com",
});
```
## Multi-city reuse
Many clients can belong to the same Bureau. When issuing a token, write `bureau_id` explicitly:
```ts
const user = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: bureau.bureau_id,
user_id: "user_123",
metadata: {
plan: "pro",
},
ttl: "7d",
});
console.log(user.user_token);
```
### Trust Boundary
Source: https://downcity.ai/en/city-sdk-docs/concepts/trust-boundary/
The Downcity security model depends on a clear trust boundary.
## Safe to keep in the client
- `Embassy User`
- `federation_url`
- `user_token`
- model IDs
- user input such as prompt, messages, size, and voice
## Must stay in a trusted environment
- provider API keys
- administrator ID, password, and session token
- Runtime env management permissions
- token issuance logic
- plans, balances, billing, and risk-control decisions
## City's boundary
City validates `user_token`, reads Runtime env, and calls the real model through service handlers. The client does not need to know the provider key, and it does not need to know how your internal billing works.
```plaintext
the client can know: which model to call
the client should not know: which provider key powers that model
```
This boundary allows pure-client products to connect directly to the Federation without exposing sensitive configuration.
### Usage and Billing
Source: https://downcity.ai/en/city-sdk-docs/concepts/usage-and-billing/
Downcity does not decide your business model for you. It gives you hooks so you can plug your own plan, balance, quota, logging, and billing systems into the AI call lifecycle.
## before hook
`before` is the right place for pre-call checks:
- whether the user is allowed to call
- whether the current plan allows this model
- whether today's quota is sufficient
- whether risk-control or rate-limiting should block the call
## after hook
`after` is the right place for post-call records:
- record provider usage
- write call logs
- deduct balance
- sync billing
- analyze cost by city
```ts
const ai = new AIService();
ai.hook
.before(async (ctx) => {
await quotaService.check(ctx.user?.user_id, ctx.variant?.id);
})
.after(async (ctx) => {
await usageService.record({
user_id: ctx.user?.user_id,
bureau_id: ctx.bureau?.bureau_id,
model: ctx.variant?.id,
output: ctx.output,
});
});
base.use(ai);
```
This lets many AI products share the same business logic instead of rebuilding it in every project.
### Try It Now
Source: https://downcity.ai/en/city-sdk-docs/quickstart/use-now/
## 1. Start City
```ts
import {
Federation,
AIService,
AIChannel,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./data.sqlite" });
const base = new Federation({ database });
class EchoChannel extends AIChannel {
constructor() {
super({ id: "echo" });
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const input_text = input.call.messages
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "model_start", request_id: crypto.randomUUID(), model_id: input.model.id });
controller.enqueue({ type: "text_start", content_id: "echo_text" });
controller.enqueue({ type: "text_delta", content_id: "echo_text", delta: `Echo: ${input_text}` });
controller.enqueue({ type: "text_finish", content_id: "echo_text" });
controller.enqueue({
type: "model_usage",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
});
controller.enqueue({ type: "model_finish", finish_reason: "stop" });
controller.close();
},
}),
};
}
}
const echo = new EchoChannel();
const ai = new AIService();
ai.use(echo.model({ id: "local-echo", upstream_model: "local-echo", name: "Local Echo" }));
base.use(ai);
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 43127, hostname: "127.0.0.1" });
```
## 2. Create Bureau & Request Token
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "http://127.0.0.1:43127",
admin_token: administrator_session_token,
});
const token = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: "user_123",
ttl: "7d",
});
```
## 3. Client Call
```ts
const user_embassy = new Embassy({
federation_url: "http://127.0.0.1:43127",
user_token: token.user_token,
});
const catalog = await user_embassy.user.ai.catalog();
const model = catalog.require("local-echo");
// SDK pathway
const stream = await model.stream({
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
});
for await (const event of stream) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
```
## Next Steps
- [AIChannel & Model Registration](/en/city-sdk-docs/service/ai/provider)
- [Calling AI Service](/en/city-sdk-docs/service/ai/calling)
- [Create City](/en/city-sdk-docs/quickstart/create-city)
### Create Federation
Source: https://downcity.ai/en/city-sdk-docs/quickstart/create-city/
The server-side runtime class is `Federation` (also called the Federation in these docs); product clients use the `City` client to reach it. See [Federation and City](/en/city-sdk-docs/concepts/federation) for the distinction.
The fastest local creation and startup path is:
```bash
fed create ./my-fed
cd my-fed
pnpm install
fed deploy
```
The default template uses Node.js and SQLite. `fed deploy` allocates a port starting at `12314`, registers the process in system-level Fed state, and interactively asks you to set an administrator ID and password. The password must be at least 12 characters; only its PBKDF2 digest is stored in the database. `fed` then uses a time-limited administrator Session Token to inspect and manage the Federation from any directory. To replace lost credentials, run `fed deploy --admin-reset`; this revokes existing administrator sessions and asks for the new credentials. The rest of this page shows the SDK path for manually embedding the Federation runtime.
Install the packages:
```bash
pnpm add @downcity/federation @downcity/database-sqlite
```
The database is the only thing you need before boot. `fed deploy` asks for an administrator ID and password; the password is stored as a PBKDF2 digest and later logins receive a session token. `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` and `BETTER_AUTH_SECRET` are generated automatically. Provider keys are written later through the Admin API or the `fed` admin workspace:
```bash
OPENAI_API_KEY="..."
OPENAI_BASE_URL="https://api.openai.com/v1"
```
Create Federation:
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./data.sqlite" });
const base = new Federation({ database });
```
The default database is stored at:
```plaintext
./data.sqlite
```
Use `@downcity/database-sqlite` locally. For production PostgreSQL, switch to `@downcity/database-postgresql`.
For Cloudflare Workers / D1, pass the D1 adapter:
```ts
import { Database } from "@downcity/database-d1";
const database = new Database({ binding: env.DB });
const base = new Federation({ database });
```
Federation initializes itself automatically on the first `health()`, `fetch()`, `table()`, or similar runtime call.
If your Federation needs its own business tables, declare them on a Service or service:
```ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { Service } from "@downcity/federation";
const notes = sqliteTable("notes", {
id: text("id").primaryKey(),
title: text("title").notNull(),
status: text("status").notNull(),
});
const notesService = new Service({
id: "notes",
tables: { notes },
});
base.use(notesService);
const table = await base.table("notes.notes");
await table.insert({
id: "note_1",
title: "First note",
status: "draft",
});
```
Tables declared by services use the same database connection as Federation. Regular projects do not need manual table creation.
Start the HTTP service:
```ts
import { serve } from "@hono/node-server";
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 3001, hostname: "127.0.0.1" });
```
This service is the shared Federation used by later cities. Management and business data are handled through your own database and city backend for now.
### Add a Model
Source: https://downcity.ai/en/city-sdk-docs/quickstart/add-model/
Create an `AIChannel`, implement its Downcity Model Protocol stream, then register an `AIModelSpec`:
```ts
import {
AIChannel,
AIService,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const channel = new DeepSeekChannel();
const ai = new AIService();
ai.use(channel.model({
id: "deepseek-v4-flash",
upstream_model: "deepseek-chat",
name: "DeepSeek V4 Flash",
context_window: 128_000,
}));
federation.use(ai);
```
This single `stream()` implementation powers `city.ai.stream()`, `CityModel`, and `/v1/ai/chat/completions`.
See [AIChannel and model registration](/en/city-sdk-docs/service/ai/provider) for reasoning, fallback, billing, and `provider_options`.
### Issue a User Token
Source: https://downcity.ai/en/city-sdk-docs/quickstart/issue-token/
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
admin_token: administrator_session_token,
});
const issued = await embassy.admin.service("accounts")
.action("tokens/issue")
.invoke({
bureau_id: "product-web",
user_id: "user_123",
metadata: { plan: "pro" },
ttl: "7d",
});
```
Federation performs the issuance. Bureau verifies User Tokens but does not issue them.
### Connect Embassy
Source: https://downcity.ai/en/city-sdk-docs/quickstart/connect-client/
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
user_token: "ub_xxx",
});
const catalog = await embassy.user.ai.catalog();
const model = catalog.get("gpt-5.4");
if (!model) throw new Error("model not found");
const result = await embassy.user.ai.text({
model,
prompt: "Write a welcome message",
});
```
Use `embassy.user.service(name)` for custom or official Services. Provider keys, model routing,
and usage accounting remain inside Federation.
### Pure-Client Products
Source: https://downcity.ai/en/city-sdk-docs/guides/pure-client-products/
“Pure-client product” does not mean “no backend at all.” It means you stop rebuilding the AI backend for every product.
Downcity lets a new product start from the frontend, extension, or app and connect to an already deployed Federation. You can validate the product UX first, then decide how much product-specific backend you really need.
## Good fits
- lightweight web tools
- Chrome extensions
- desktop shells
- mobile entries
- client demos
- internal tools
## Recommended boundary
The client owns:
- UI and interaction
- calling Embassy User
- using the product-configured `bureau_id` when login starts
- storing or refreshing `user_token`
The Federation owns:
- provider keys
- model directory
- `user_token` verification
- service routing
- usage records
- hooks and billing logic
## The most common minimum shape
```plaintext
City frontend
↓
Your login / token-issuing endpoint
↓
Federation
```
That product backend can stay very thin at first:
- user login
- requesting a User Token through Embassy Admin
- returning `user_token` to the client
Normal service requests do not submit `bureau_id` again; Federation reads it from the verified
`user_token`.
## Why this fits early products better
- you stop copying provider integrations for every AI idea
- the frontend can ship much earlier
- multiple products share one AI calling, logging, and quota layer
If the product gets more complex later, then add more business backend. Do not duplicate the whole AI infrastructure on day one.
### Many Products, One Federation
Source: https://downcity.ai/en/city-sdk-docs/guides/multi-product-city/
When many products share one runtime, the goal is not “save one server.” The goal is to separate **shared infrastructure** from **product-specific differences**.
What should be shared:
- provider env
- model directory
- service routing
- hooks, usage, and logs
- `user_token` verification
What should stay in each product:
- pages and interaction
- the user system
- plan, order, and balance rules
- product-specific brand, positioning, and pricing
## One diagram for the reuse boundary
Product A
For example, a web tool.
bureau_id = web
Product B
For example, a Chrome extension.
bureau_id = extension
Product C
For example, a client demo or internal tool.
bureau_id = demo
→
Federation
Reuses the model directory, provider env, service logic, usage records, and hooks.
## How to think about `bureau_id`
`bureau_id` is a product boundary, not a model boundary.
It is useful for:
- identifying which product sent the call
- deciding whether a product is still `active`
- telling hooks where usage should be recorded
- separating one user’s activity across different products
A common pattern is one stable `bureau_id` per product:
```ts
const issued = await admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: "user_123",
metadata: {
plan: "starter",
channel: "extension",
},
});
```
## What belongs in token metadata
`bureau_id` says *which product this is*. Token metadata is better for *the user’s business state inside that product*.
Common fields:
- `plan`
- `organization_id`
- `channel`
- `feature_flag`
- `team_id`
That lets hooks differentiate behavior with `ctx.bureau` and `ctx.user.metadata` instead of cloning City for each city.
## Recommended layering
### City side
- keeps routes, UI, and business experience
- uses `Embassy User`
- only holds `user_token` (which contains `bureau_id`)
### Business backend
- owns login, orders, balance, subscriptions, and business data
- uses `Embassy Admin` in a trusted environment
- issues `user_token` after a successful login
### City
- owns the model directory
- owns provider calls
- owns usage and hooks
- owns the reusable AI service layer
## When to create a new city
Usually worth creating a new city when:
- it is a separate city or market
- it needs to be paused or activated independently
- it needs independent usage reporting or business strategy
- you need to separate web app, extension, client demo, and other city surfaces
Usually not worth creating a new city when:
- it is only another page in the same city
- it is only a feature flag inside the same city
- it is just another conversation/session for one user
Those are better kept inside your own business system or token metadata.
### Cloudflare Workers
Source: https://downcity.ai/en/city-sdk-docs/guides/cloudflare-workers/
If you want to deploy Downcity on Cloudflare Workers, the recommended path is not a separate edge package. Wire `@downcity/agent` directly into the Workers runtime.
The public repository includes `templates/edgefed` as a developer starter. Downcity's private official Worker implementation lives outside the public repository.
## What this path actually solves
The main difference from Node.js is not the HTTP entry. It is the runtime resource model:
- the database usually comes from a binding such as `env.DB`
- environment values are not just a local `.env` file
- the request origin may need to be synchronized into services on every request
- Worker isolates are reused, so runtime cache behavior must be managed explicitly
That is why this is better explained as a guide than as an empty npm package.
## The minimum shape
Federation needs a D1 Database Adapter:
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-d1";
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env) {
const database = new Database({ binding: env.DB });
const base = new Federation({ database });
await base.health();
return base.fetch(request);
},
};
```
The adapter owns the D1 runtime differences:
- only `@downcity/database-d1` receives the D1 binding
- the adapter combines Drizzle with snapshot conflict retries and atomic commits
- City and services never receive the raw D1 binding
- the current request origin can be synchronized before calls that need OAuth callback URLs
## Use `templates/edgefed` as a starter
The starter in `templates/edgefed/src/index.ts` keeps only the boundaries required to connect Federation to a Worker:
- wrap `env.DB` with `@downcity/database-d1`
- reuse the Federation instance within a Worker isolate
- delegate the HTTP request to Federation
Product services, models, and providers should be composed by the product and do not belong in the Edge runtime template.
## Boundary with the Node path
Worker and Node use the same mental model: create the appropriate Database Adapter, then pass it to `new Federation({ database })`.
- Node.js local projects use `@downcity/database-sqlite`
- Node.js production deployments can use `@downcity/database-postgresql`
- Workers / D1 use `@downcity/database-d1`
## Next
- If you still need the City mental model first, read [City](/en/city-sdk-docs/packages/federation/federation)
- If you need to manage provider keys, continue with [Provider environment](/en/city-sdk-docs/guides/provider-env)
- For the minimal Worker integration, open `templates/edgefed/src/index.ts`
### Auth and `user_token`
Source: https://downcity.ai/en/city-sdk-docs/guides/auth-and-user-token/
Downcity does not host your user system. It only requires a verifiable `user_token` on product-side requests.
## If you already have login
After a user logs in, use Embassy Admin to request a Downcity token from Federation:
```ts
const user = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: session.user.id,
metadata: {
plan: session.user.plan,
},
ttl: "7d",
});
```
Then give the product side two values:
- `bureau_id`
- `user_token`
## If you do not have a heavy backend yet
For new pure-client products, keep token issuance near one Federation instead of building a separate service per product.
## What the token should not do
- Do not put provider keys into the token.
- Do not put private billing rules into the token.
- Do not let the client hold administrator IDs, passwords, or session tokens.
The token only needs to express who the user is and which business metadata should travel with the request.
### AIChannel Environment Variables
Source: https://downcity.ai/en/city-sdk-docs/guides/provider-env/
Declare the key on the Channel:
```ts
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(input: AIChannelStreamInput) {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
```
`env` declares the variables shown in the model catalog's admin view. Add multiple entries when a Channel needs more than one value.
Values are stored in Federation's env table and read at execution time with `input.env(key)`. They never enter `CityModelDescriptor` or the client runtime.
### Model Adapters
Source: https://downcity.ai/en/city-sdk-docs/guides/model-adapters/
Downcity has one core model protocol. OpenAI Chat Completions is adapted only at the HTTP boundary:
```text
City SDK / CityModel ------\
-> ModelCall -> AIService -> AIChannel.stream()
OpenAI Chat Completions ---/ -> ModelStreamEvent
```
Use `stream_openai_compatible_model()` for an OpenAI-compatible upstream. For a private HTTP protocol, provide an Adapter that maps `ModelCall` into the vendor request and returns `AIChannelStreamResult`. AIService does not know the vendor request format.
OpenAI Chat Completions is an HTTP Adapter around the same Model Protocol path. There is no raw passthrough and no second model execution path, so tools, fallback, reasoning, metering, and billing behave consistently across clients.
Keep upstream-specific behavior in the Channel:
- credentials and base URL;
- upstream model factory and protocol selection;
- model-specific `provider_options`;
- usage interpretation and billing;
- conversion from private streams to `ModelStreamEvent`.
Keep Federation behavior in AIService:
- model registry and public catalog;
- authentication and availability;
- media fallback and reasoning validation;
- metering, balance checks, and charge settlement.
### Hooks and Billing Logic
Source: https://downcity.ai/en/city-sdk-docs/guides/hooks-and-billing/
Downcity hooks have three layers: Action -> Service -> Global. Execution order runs from outer to inner layers:
```txt
global.before
-> service.before
-> action.before
-> action.run() <- core logic
-> action.after <- billing
-> service.after
-> global.after
```
## Action-level hooks
Each Action has its own hooks, which makes them a good fit for product-specific logic:
```ts
const zh2en = svc.action("zh2en", async (ctx) => {
return await translate(ctx.input.text, "en");
});
// Check balance before the call
zh2en.before(async (ctx) => {
const bal = await balance.get(ctx.user!.user_id);
if (bal <= 0) throw new Error("Insufficient balance");
});
// Deduct balance after the call (ctx.output is available)
zh2en.after(async (ctx) => {
await deductBalance(ctx.user!.user_id, 10);
await logUsage(ctx);
});
```
## Service-level hooks
All Actions under the same Service share them:
```ts
svc.hook.before(async (ctx) => {
console.log(`${ctx.user?.id} -> ${ctx.service?.id}.${ctx.action?.id}`);
});
svc.hook.after(async (ctx) => {
// aggregate metrics, unified logs
});
```
## Comparison
| | Action hook | Service hook |
|---|---|---|
| Granularity | Single action | All actions in the service |
| Use cases | Balance checks, billing, per-action logs | Shared validation, aggregate metrics |
| Registration | `action.before(fn)` | `svc.hook.before(fn)` |
## Key fields inside ctx
```ts
interface Context {
input: Record // request payload
output?: unknown // Action result, available in after
user?: { user_id, metadata } // current user
bureau?: { bureau_id, status } // verified Bureau identity
service?: { id, name } // current Service
action?: { id } // current Action
}
```
### Account Login
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/client/auth-flows/
Account operations live under `embassy.user.account`.
```ts
const providers = await embassy.user.account.providers();
const login = await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: {
email: "user@example.com",
password: "password",
},
});
```
Email login requires completed email verification. For multi-step providers, use `login_start()` and `continue()`; the same account accessor retains the initiating proof. `status()` only queries status. After completion, `exchange()` obtains and stores the User Token, retaining a stable exchange ID across transport retries. `login()` handles the exchange automatically.
For OAuth, pass `on_authorize: async (url, user_code) => { ... }` to `login()`. Display `user_code`, when supplied, in the original client before opening `url`; users enter that code on the browser confirmation page. Use `client_type: "cli"`, `"desktop"`, or `"device"` for these flows. Direct browser `"web"` flows require same-origin requests and the browser binding cookie. Never put the login secret in URLs or logs.
`token()` reads the current User Token. `await embassy.user.account.logout()` revokes that token before clearing local credentials; failure retains it for retry. Bureau validates new protected requests online with Federation and rejects access if validation is unavailable. Existing streams are not forcibly interrupted.
After login, use the same `embassy.user` object for `current()`, AI, payment, and Services.
### User and Admin Services
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/client/service-calls/
User and admin calls share the same Service protocol but use separate identity domains.
```ts
const user_services = await embassy.user.list_services();
const profile = await embassy.user.current();
const result = await embassy.user.service("rewrite")
.action("formal")
.invoke({ text: "hello" });
const admin_services = await embassy.admin.list_services();
const env = await embassy.admin.env.list();
```
Use `embassy.user.ai` for the Federation model catalog and AI execution, and
`embassy.user.payment` for payment methods. Admin-only management stays under
`embassy.admin`; do not pass administrator credentials through the user domain.
### Federation
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/federation/
`@downcity/federation`'s `Federation` is the long-running server container of Downcity. It is where a Database Adapter, services, auth, and the shared `/v1/*` route space come together. Product-side access uses the `City` client; see [Federation and City](/en/city-sdk-docs/concepts/federation) for the distinction.
## What this concept means
A simple way to think about Federation is:
> one reusable AI infrastructure runtime process that many products can keep calling over time.
That process owns:
- the Database Adapter
- the service registry
- service mounting
- the HTTP route surface
- city, token, and env infrastructure
## When this page matters
- you are about to run a real Federation for the first time
- you want to understand why `Federation` only needs a Database Adapter
- you want clarity on `fetch()`, and `serve()`
- you want to mount Federation into Node, Workers, or another HTTP host
## Minimal runnable example
```ts
import { AIService, Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
import { serve } from "@hono/node-server";
const database = new Database({ filename: "./.base/downcity.sqlite" });
const base = new Federation({ database });
const ai = new AIService();
ai.use({
id: "local-echo",
name: "Local Echo",
actions: {
text: async (ctx) => ({
id: crypto.randomUUID(),
role: "assistant",
parts: [
{
type: "text",
text: String(ctx.input.prompt ?? ""),
state: "done",
},
],
}),
},
});
base.use(ai);
await base.health();
serve({
fetch: (request) => base.fetch(request),
port: 43127,
hostname: "127.0.0.1",
});
```
The important ideas in that example are:
1. `database` decides which Database Adapter Federation uses.
2. `base.use(...)` decides which capabilities Federation exposes.
3. `base.fetch()` decides how Federation enters your HTTP layer.
## Production startup and database preparation
Local development defaults to `database_initialization: "automatic"`, which prepares tables, indexes and system data during initialization. Production hosts can use `"prepared"` to move that work out of the request startup path.
Before publishing, create a fresh Federation with the complete Service registration and call `await federation.prepare_database()`. This prepares the database, runs Service preparation and validates initialization before writing the preparation version marker. Abort the release if it fails. Request instances use the same Service configuration with `database_initialization: "prepared"`; startup loads configuration and signing keys and rejects an unprepared database.
Call `prepare_database()` before `health()` or request handling on that instance. Never expose it as a public HTTP endpoint. The preparation marker does not replace application migration history or coordinated schema releases.
For a full migration, call `prepare_schema()` on the empty target first. It creates structures without generating Federation identities, configuration or signing keys, and does not run Service data repairs. Import and verify existing data before `prepare_database()` establishes runtime readiness using the original identities and keys.
Use `on_startup_timing: ({ phase, duration_ms, ok }) => console.info({ phase, duration_ms, ok })` to observe startup stages without logging credentials or business data.
Schedule `await federation.maintenance()` to clean expired revocations and Service data. Credential expiry is still checked during requests. Custom Services extend `on_prepare_database()` and `on_maintenance()`; `on_init()` handles instance initialization only. Hosts should rebuild failed containers with backoff rather than repeat route installation on partially initialized objects.
## Scenario 1: Federation is the main service
If you want Federation itself to be the main HTTP entry, `serve()` is the clearest mental model:
```ts
import { serve } from "@hono/node-server";
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 3001, hostname: "127.0.0.1" });
```
## Scenario 2: Federation is mounted into your existing server
If you already have a server framework, handing requests into Federation is often the cleaner pattern:
```ts
export default {
async fetch(request: Request) {
return base.fetch(request);
},
};
```
### Concurrent Cloudflare Worker cold starts
A single Worker deployment can start multiple isolates at the same time. Each isolate has independent memory while sharing the same D1 database, so a module-level Promise only prevents duplicate initialization inside one isolate and is not a global lock.
Federation atomically initializes its system env and Ed25519 user token signing key in the shared database, with a unique constraint that allows only one active signing key. If an older SDK left multiple active keys behind, startup keeps the earliest key and changes the others to retired. Retired keys remain in JWKS so existing tokens can still be verified.
## Scenario 3: Local HTTP
When Federation only needs to be called by trusted processes on the same machine, expose its standard `fetch` handler through your local HTTP server:
```ts
import { Federation } from "@downcity/federation";
import { serve } from "@hono/node-server";
const base = new Federation({ database });
serve({
fetch: (request) => base.fetch(request),
hostname: "127.0.0.1",
port: 15315,
});
const federation_url = "http://127.0.0.1:15315";
```
Then point Embassy at that HTTP URL:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url,
admin_token: administrator_session_token,
});
await embassy.admin.list_services();
```
Local HTTP uses the same identity model as remote HTTP: Embassy Admin uses an administrator session token, while authenticated user actions require a normal `user_token`.
If `AccountsService` is configured with `local_login: true`, `/v1/accounts/providers` only returns the local login method. Call `POST accounts.login/start` with `provider: "local"`, an explicit `client_type`, and `bureau_id` to receive `login_id` and `login_secret`. Keep the secret in the initiating client. `POST accounts.login/result` accepts both fields and returns status only. Use `POST accounts.login/exchange` with both fields and a stable random `exchange_id` to obtain `user_token`, then recreate or update Embassy User with that token (which contains `bureau_id`). Reuse the exchange ID on transport retries.
## Why Federation only takes a Database Adapter
`@downcity/federation` owns the logic that should stay stable across runtimes:
- service lifecycle
- action routes
- token auth
- city / env / store infrastructure
The host runtime only needs to create the appropriate adapter:
- Node.js can use `@downcity/database-sqlite` or `@downcity/database-postgresql`
- Cloudflare Workers can use `@downcity/database-d1`
- Federation writes required runtime env into the built-in `env` table on boot
## Default Storage
When a Federation needs to move runtime files into first-party storage, register a default storage backend:
```ts
import { Federation, R2Storage } from "@downcity/federation";
const base = new Federation({ database });
base.storage(R2Storage({
bucket: env.DOWNCITY_STORAGE,
public_url_prefix: env.DOWNCITY_STORAGE_PUBLIC_URL_PREFIX,
}));
```
Services can then access it through `ctx.storage`. Built-in AI image jobs use this storage after `image_fetch` succeeds to move remote file part URLs into your own bucket. If storage fails, the original upstream URL is kept and the image job still succeeds.
## Common API surface
- `new Federation({ database })`
- `base.use(...)`
- `base.storage(...)`
- `base.fetch(request)`
- `base.health()`
- `base.table(name)`
## Common misunderstandings
### Federation is the runtime, not the whole SDK
Federation is the long-running server runtime. Product-side calling uses the `City` client; see [Client SDK](/en/city-sdk-docs/packages/federation/client).
### Federation is not only an HTTP proxy
It also owns built-in tables, token and env infrastructure, service data layers, and hook lifecycle.
### Federation is not one provider
Providers, models, services, and custom services are capabilities mounted into Federation, not the Federation itself.
## Read next
- For what actually gets mounted into Federation, read [Service and Action](/en/city-sdk-docs/packages/federation/service-and-action)
- For hooks and extension points, read [Hooks and Service Mounting](/en/city-sdk-docs/packages/federation/hooks-and-service)
- For persistence and tables, read [Store and Table](/en/city-sdk-docs/packages/federation/store-and-table)
- For lower-level method details, read [Federation API](/en/city-sdk-docs/reference/federation)
### Database Adapters
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/database-adapters/
Federation no longer accepts a raw Drizzle database. It accepts a `Database` instance derived from `@downcity/federation`; the adapter owns the driver, transactions, DDL, and connection lifecycle.
## SQLite
```bash
pnpm add @downcity/federation @downcity/database-sqlite
```
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./federation.sqlite" });
const federation = new Federation({ database });
```
Use `filename: ":memory:"` in tests. File databases enable WAL by default.
## Cloudflare D1
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-d1";
const database = new Database({ binding: env.DB });
const federation = new Federation({ database });
```
The D1 adapter provides the same `context.transaction()` contract through snapshots, conflict retries, and atomic `batch()` commits. Reads inside a transaction observe its buffered writes.
## PostgreSQL
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-postgresql";
const database = new Database({ url: process.env.DATABASE_URL! });
const federation = new Federation({ database });
```
The PostgreSQL adapter uses postgres-js and native Drizzle transactions.
## Service boundary and lifecycle
Services use `context.table()` and `context.transaction()`. Selected official integrations can use the restricted `context.database.query()` / `atomic()` surface, but never receive the underlying driver client.
`Federation` owns the supplied database. Call `await federation.dispose()` during shutdown to release adapter resources. Disposal is idempotent, and operations after disposal fail.
### Service and Action
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/service-and-action/
`Service` and `Action` are the main business organization units inside `@downcity/federation`.
Keep one sentence in mind:
> a Service is a long-lived group of capabilities, and an Action is the concrete callable entry.
## What this concept means
Every Action is simultaneously:
- a business entry point
- an HTTP route node
- a hook target
- an auth boundary
## When you should write a custom service
- you want city business actions, not raw model inference only
- you want one business entry shared across frontend, app, and internal tools
- you want that capability to participate in hooks, auth, usage, and service governance
Typical examples:
- rewriting content
- generating reports
- running workflows
- processing business data
## When AIService is the better starting point
If you mainly want to expose `text`, `stream`, `image`, or `video`, then `AIService` is usually the cleaner first mental model.
## Minimal custom service example
```ts
import { Federation, Service } from "@downcity/federation";
const base = new Federation({ database });
const translate = new Service({
id: "translate",
name: "Translate",
});
translate.action("zh2en", async (ctx) => {
return {
text: await runTranslate(String(ctx.input.text ?? ""), "en"),
};
});
translate.action("list", async () => {
return {
items: ["zh2en"],
};
}, {
method: "GET",
});
base.use(translate);
```
## How routes are exposed
Those Actions automatically enter the shared route space:
```txt
POST /v1/translate/zh2en
GET /v1/translate/list
```
That is why the product side can stay unified:
```ts
const result = await client
.service("translate")
.action("zh2en")
.invoke<{ text: string }>({
text: "Hello Downcity",
});
const list = await city.service("translate").get("list");
```
## Common scenarios
### Scenario 1: city business action
```ts
const writer = new Service({ id: "writer", name: "Writer" });
writer.action("draft", async (ctx) => {
return {
title: "Draft",
topic: ctx.input.topic,
};
});
```
### Scenario 2: admin-only action
```ts
writer.action("remove", async (ctx) => {
await removeDraft(String(ctx.input.id));
return { ok: true };
}, {
auth: ["admin"],
});
```
### Scenario 3: guest entry
```ts
writer.action("webhook", async () => {
return { accepted: true };
}, {
auth: [],
});
```
## Common API surface
- `new Service({ id, name })`
- `service.action(name, handler, options?)`
- `options.method: "GET" | "POST"`
- `options.auth: ["user"] | ["admin"] | []`
- `base.use(service)`
- `city.service(id).action(name).invoke(input)`
- `city.service(id).get(path, query?)`
## Common misunderstandings
### An Action is not just a function
It eventually becomes a route, an auth boundary, a hook target, and a usage recording point.
### Official services and custom services do not need separate call patterns
From the product side, both still go through `city.service("service_id").action("action_id").invoke(...)`.
### GET and POST are not cosmetic
Read-oriented operations fit GET better. Execution and mutation fit POST better.
## Read next
- For hooks and extension around actions, read [Hooks and Service Mounting](/en/city-sdk-docs/packages/federation/hooks-and-service)
- For product-side calling, read [@downcity/federation](/en/city-sdk-docs/packages/federation/federation)
- For lower-level method details, read [City API](/en/city-sdk-docs/reference/federation)
### Hooks and Service Mounting
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/hooks-and-service/
A lot of shared capability in Downcity is not created by copying logic into every action. It enters City through hooks and services.
## What each concept solves
The easiest split is:
- hooks: insert rules into an existing call lifecycle
- services: add a full reusable capability set into City
That is why auth, usage, and payment do not become separate systems. They become extension points inside the same Federation.
## When to think hook first
- you want logic before or after existing calls
- the logic is cross-cutting and should not live in every action
- the capability is local to your project and does not need to be its own package
Typical examples:
- quotas
- logging
- risk checks
- charging after a successful call
## When to think service first
- the capability should be reused across many products
- it is more than before or after logic and needs its own service or tables
- you want product-side callers to reach it through a stable service boundary
Typical examples:
- `@downcity/services`
- `@downcity/services`
- `@downcity/services`
## First understand the three hook positions
```txt
global
-> service
-> action
```
Their meanings are:
- global: shared by all services and actions
- service: shared by all actions in one service
- action: only affects one concrete action
## Common service mounting example
```ts
import { AIService, Federation } from "@downcity/federation";
import {
AccountsService,
CreditsService,
PaymentService,
googleAccountsProvider,
stripePaymentProvider,
UsageService,
} from "@downcity/services";
const base = new Federation({ database });
const credits = new CreditsService();
const ai = new AIService({ credits });
base.use(new AccountsService({
token_ttl: "7d",
providers: [
googleAccountsProvider(),
],
}));
base.use(credits);
base.use(ai);
base.use(new PaymentService({
resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000 }),
on_paid: async (record) => {
await credits.topup({
card: { kind: "primary", user_id: record.user_id },
credits: record.credits,
source: "payment",
ref: record.payment_id,
idempotency_key: `payment:${record.payment_id}`,
});
},
providers: [
stripePaymentProvider({
secret_key: process.env.STRIPE_SECRET_KEY,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
}),
],
}));
base.use(new UsageService({
ai_usage_reader: ai,
credits_usage_reader: credits,
}));
```
In this setup, `AccountsService` owns the accounts HTTP surface. Mounting it once exposes `/providers`, the unified login entry, `me`, admin reads, the better-auth passthrough route, and the OAuth callback route. Concrete login methods appear only when the matching provider is mounted and configured.
`UsageService` combines technical AI usage from `AIService` with applied charges from `CreditsService`; it does not record global service-hook events.
## How product-side callers reach services
Once mounted, services still look like normal services from the product side:
```ts
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
client_type: "cli",
bureau_id: "demo",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
input: {
email: "user@example.com",
password: "password123",
},
});
const status = await guest.service("accounts").action("login/result").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
});
// Use a verified email; retain this ID across exchange transport retries.
const exchange_id = crypto.randomUUID();
if (status.status !== "done") throw new Error("Login is not ready");
const session = await guest.service("accounts").action("login/exchange").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
exchange_id,
});
const usage = await user.service("usage").get("me", {
from: "2026-08-01",
to: "2026-08-31",
timezone: "America/Los_Angeles",
});
const recent_usage = await user.service("usage").get("me/recent", {
limit: 20,
});
const methods = await guest.service("payment").get("methods");
const checkout = await user.service("payment").action("checkout/create").invoke({
method_id: "stripe",
topup_amount_minor: 500,
idempotency_key: "order_123",
});
```
That means `payment` is both the discovery entry and the execution entry; Stripe is selected as a provider.
## Relationship between hooks, services, and services
A useful chain is:
```txt
service / action
-> hooks add lifecycle rules
-> services add reusable services, tables, and management capability
-> clients still call everything through /v1/*
```
## Common API surface
- `base.use(service)`
- `guest.service("accounts")...`
- `guest.service("payment").get("methods")`
- `user.service("usage")...`
- `admin.service("payment").get("payments")`
## Common misunderstandings
### A service is not just another npm package
It often represents a whole boundary of services, tables, admin surfaces, and optional hook behavior.
### Hooks are not a place to hide full business features
Hooks are best for cross-cutting governance logic, not for turning every feature into middleware.
### Services do not replace City
Services add capability into the Federation. The product-side calling surface is still [@downcity/federation](/en/city-sdk-docs/packages/federation/federation).
## Read next
- For unified product-side service calls, read [@downcity/federation](/en/city-sdk-docs/packages/federation/federation)
- For auth capability, read [@downcity/services](/en/payments)
- For usage recording, read [@downcity/services](/en/payments)
- For one-time Stripe topups, read [@downcity/services](/en/payments)
### Store and Table
Source: https://downcity.ai/en/city-sdk-docs/packages/federation/store-and-table/
Downcity is often seen through AI calls, but its long-term reliability depends on a clear data layer.
## What this concept means
Many Federation capabilities are not just in-memory state. They must persist as data facts:
- whether a city is active
- which token belongs to whom
- what the current env value is
- whether a call has been recorded as usage
- whether a user currently has entitlement
That is why `@downcity/federation` has not only routes and services, but also a store and table layer.
## When you need to care about this layer directly
- you are checking whether a service actually wrote its tables
- you want your own business data to interact with Federation data
- you need controlled trusted-side reads or writes
- you are debugging operations and need facts, not only HTTP responses
## What `base.table()` represents
`base.table(name)` is Federation's controlled exposure of table capability inside one runtime and lifecycle.
## Minimal example: reading and writing tables
```ts
const cities = await base.table("cities");
const env = await base.table("env");
const notes = await base.table("notes.notes");
await notes.insert({
id: "note_1",
title: "First note",
status: "draft",
});
const draftNotes = await notes.select({
status: "draft",
});
```
## What tables you usually encounter
### Built-in Federation tables
- `cities`
- `env`
### Official service tables
Different services bring their own tables, such as accounts-related user or session tables, usage event tables, and Stripe entitlement or webhook event tables.
### Your own business tables
If your services define their own tables, they also live in the same runtime-governed data layer.
## Common scenarios
### Scenario 1: confirm whether data really landed
```ts
const usageEvents = await base.table("service_usage_events");
const rows = await usageEvents.select();
```
### Scenario 2: controlled trusted-side updates
```ts
const envTable = await base.table("env");
await envTable.update({
where: { key: "OPENAI_API_KEY" },
values: { value: "new-secret" },
});
```
### Scenario 3: deletion and cleanup
```ts
const notes = await base.table("notes.notes");
await notes.delete({ id: "note_1" });
```
## Common API surface
- `await base.table(name)`
- `table.select(where?)`
- `table.insert(values)`
- `table.update({ where, values })`
- `table.delete(where)`
## Common misunderstandings
### Federation is not only an API proxy
Many management and city capabilities depend on persistent data facts behind the route layer.
### `table()` is not a reason to skip good service design
You should still prefer services, and `Embassy Admin` for city-facing or managed entry points.
### Frontends should not treat tables as their API
City-side callers should continue to use `Embassy User` and `Embassy Admin`, not raw table access.
## Read next
- For how the database is wired in, read [Federation](/en/city-sdk-docs/packages/federation/federation)
- For terminal inspection of these tables, read [CLI](/en/city-sdk-docs/packages/cli)
- For service-specific data expectations, read the relevant service package docs together with this page
### HTTP and RPC
Source: https://downcity.ai/en/city-sdk-docs/packages/city/transport/
`City` owns one HTTP transport and one RPC transport. Both route by `agent_id` to the same Agent
collection and by `workspace_id` to a Workspace owned by City.
```ts
await city.listen({
http: { host: "127.0.0.1", port: 5314 },
rpc: { host: "127.0.0.1", port: 15314 },
});
await city.close(); // transports only; the creating host still disposes Agents
```
For separate lifecycle control, City can start either transport directly:
```ts
const http = await city.http({ host: "127.0.0.1", port: 5314 });
const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 });
```
HTTP uses `http://host:port/agents//workspaces/` and RPC uses
`rpc://host:port//`.
There are no Agent-level transport constructors; external services belong to City.
### @downcity/city/local
Source: https://downcity.ai/en/city-sdk-docs/packages/local/
`@downcity/city/local` provides the local host capabilities shared by the CLI and Desktop:
the SQLite adapter, paths, product schemas, repositories, environment assembly, and Power loader.
```ts
import {
LocalDatabase,
AgentRepository,
WorkspaceRepository,
ensure_local_schema,
} from "@downcity/city/local";
const database = new LocalDatabase({ filename: database_path });
ensure_local_schema(database);
const workspaces = new WorkspaceRepository(database);
const agents = new AgentRepository(root_path);
```
The CLI and Desktop create these dependencies in their own composition roots, read configuration,
and explicitly call `new Agent()`. The local subpath does not create an Agent, Workspace, Model, or
City. Workspace and platform settings are stored as plaintext JSON, with `0600` permissions on the
local database for user-level isolation.
### CLI Quickstart
Source: https://downcity.ai/en/city-sdk-docs/packages/cli/quickstart/
The `downcity` package installs two command families:
- `fed` / `downfed`: manages and deploys Federations.
- `city` / `downcity`: manages local Agents, local powers, chat/gateway, and the local user's selected Federation login.
## Install
```bash
npm i -g downcity
```
Check versions:
```bash
fed -v
city -v
```
## Manage Federations With fed
Use `fed` when you are creating, deploying, or operating the shared backend.
```bash
fed
fed create .
fed create ./edge-fed --template cloudflare-workers
fed create ./custom-fed --template git://example.com/custom-fed.git
fed deploy
fed deploy --dry-run
fed deploy --verify
```
`fed create` generates a Local Node.js + SQLite project by default. `--template` can select `cloudflare-workers` or a Git template. Project identity and deployment intent live in `federation.json`.
`fed deploy` deploys the current project whether the destination is local or remote. Local deploys allocate a port starting at `12314` and start a managed process; the first deploy interactively sets an administrator ID and password, while ordinary redeploys preserve the existing administrator. Redeploying the same `fed_id` replaces the old process without changing administrator credentials. Cloudflare Workers deploys prepare D1, Queue, and R2, publish the Worker, then create a time-limited administrator Session Token for the local registry.
Manage deployed Federations:
```bash
fed server add
fed server
fed query GET /health
fed query GET /v1/ai/models
fed query POST /v1/ai/image/result --data '{"job_id":"..."}'
```
Federation URLs, active state, administrator IDs, administrator Session Tokens, local PIDs, ports, logs, and deployment status are stored in the system-level Federation registry and do not depend on the current directory. downfed has no default Federation. `fed deploy` only registers or updates an instance and never changes `active_server_url`; a Federation becomes active only when the user explicitly opens it in downfed. `fed query` adds `Authorization: Bearer` when a valid administrator Session exists and asks you to log in again when it expires.
## federation.json
```json
{
"schema": 1,
"type": "federation",
"id": "fed_example",
"name": "example",
"entry": "src/index.ts",
"deployment": {
"target": "local",
"scripts": {
"build": "pnpm typecheck",
"deploy": "pnpm start"
}
}
}
```
`deployment.scripts` is optional. Missing scripts use the target's built-in deployer; a configured `build` or `deploy` script replaces only that stage.
## Manage Local Agents And Login State With city
Use `city` when the local machine needs to join a Federation, sign in as a user, run Agents, or open chat.
```bash
city
city federation status
city federation join https://your-federation.example.com
city federation login
city agent create .
city agent list
city on
city agent chat
```
`city agent chat` keeps one live subscription to the active Session and merges Message, Part, text, reasoning, Tool, Interaction, and configuration updates as they arrive. Opening an Agent enters its most recent Session and follows that Session's Workspace; when the Agent has no history yet, Chat opens in the current Workspace and creates a new Session. The header shows the active Session model. Use `/model` to choose from the current user's available model catalog, or `/model ` to switch directly. A model change affects only the active Session and reaches the UI through a Session Mutation; it does not change the Agent default model. When `/session` switches conversations, the TUI subscribes to the new Session before loading its full snapshot and then merges any buffered events. Visible reasoning text is rendered in Assistant Part order with subdued styling; reasoning metadata alone does not produce invented reasoning content.
`city federation ...` only manages local Federation membership and user login state. It does not deploy Federations.
Each CLI City start has a unique instance ID. `city off` verifies the PID and instance ID together. If pid/meta files are stale or the PID has been reused by another process, the CLI does not signal that unrelated process.
After selecting an OAuth provider such as GitHub or Google, the CLI always prints the `authorization_url`. In a local graphical session it also attempts to open the default browser. When connected to a VPS over SSH or running headlessly, open that URL in your own browser while the CLI waits for the login result.
## Default Storage In federation.json
Cloudflare Workers Federation projects can declare default storage in `federation.json`. R2 is currently supported:
```json
{
"deployment": {
"target": "cloudflare-workers",
"resources": {
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "downcity-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
During deploy, `fed deploy` first checks whether a same-name R2 bucket exists. If it is missing and the deploy is not a dry run, the CLI creates it automatically. The CLI then writes the binding to the temporary `wrangler.toml` as `[[r2_buckets]]` and emits `public_url_prefix` as a Worker var.
## Common Misunderstandings
### `city deploy` is not the deploy entry
Use `fed deploy` to deploy Federations.
### `city create` is not the Federation project entry
Use `fed create` to scaffold a Federation project. Use `city agent create` to create a local Agent project.
### fed and city have different roles
- `fed`: manages shared backend and admin-side capabilities.
- `city`: manages local Agents and user-side connection state.
## Read Next
- To deploy a Federation, read [Deploy to Cloudflare Workers](/en/city-sdk-docs/operations/deploy-cloudflare-workers)
- To debug local state, read [Operations and Debugging](/en/city-sdk-docs/packages/cli/operations-and-debug)
- To understand the Federation runtime, read [Federation](/en/city-sdk-docs/packages/federation/federation)
### Models and Tables
Source: https://downcity.ai/en/city-sdk-docs/packages/cli/models-and-tables/
The CLI is most often used for two things:
- models
- tables
## Why models are a first debugging stop
Many "the AI path is wrong" issues are not provider failures. They are model-layer issues:
- was the model actually registered?
- is the configured model id the one you expected?
- do the stored model rows look correct?
## Why tables matter just as much
When you debug runtime behavior, you usually end up checking tables such as:
- `cities`
- `env`
- usage event tables
- accounts-related tables
- entitlement and webhook tables
## Common model-focused entry points
```bash
city
city federation login
city federation status
```
Use these commands to:
- inspect models exposed by Federation AIService
- select a City base and sign in with City
- confirm local Agents can resolve City connection settings before startup
## Common table-debugging scenarios
### Scenario 1: did a service really create or write data?
If you enabled accounts, usage, or Stripe payment services, what matters is whether your actual City has the tables and rows you expect.
### Scenario 2: why does usage, payment, or auth look wrong?
Looking at the tables first is often the fastest way to distinguish:
- the call never happened
- the service was not enabled
- the tables were not created
- the write failed
## A common workflow
```bash
city
# or start a local agent
city agent start .
city federation status
```
Then continue inside the interactive panel to inspect:
- tables
- schema
- rows
## Relationship with the City data layer
The CLI does not define the data model. It exposes the runtime data layer that City already owns.
That is why this page fits naturally with [Store and Table](/en/city-sdk-docs/packages/federation/store-and-table):
- `@downcity/agent` owns the controlled data layer
- `downcity` owns the human inspection and debugging entry
## Common misunderstandings
### Table inspection is not "low-level debugging"
For City, services, usage, and payment, table facts are often the fastest truth source.
### The CLI is not a general-purpose DB IDE
It is better understood as a City-oriented operations tool.
### Looking at models alone is not always enough
Models, service tables, usage events, and env data can all affect one city flow, so you often need to inspect them together.
## Read next
- For operational decision-making with the CLI, read [Operations and Debugging](/en/city-sdk-docs/packages/cli/operations-and-debug)
- For the data layer behind these tables, read [Store and Table](/en/city-sdk-docs/packages/federation/store-and-table)
### Operations and Debugging
Source: https://downcity.ai/en/city-sdk-docs/packages/cli/operations-and-debug/
The CLI and `Embassy Admin` can both help you debug Downcity, but they fit different work: `fed/downfed` manages Federation, `city/downcity` manages local Agents and local Federation connections, and `Embassy Admin` is for trusted programmatic management.
## The simplest rule first
If your thought is:
- "I need to see the facts right now"
start with the CLI.
If your thought is:
- "this action should live in the system long term"
move toward `Embassy Admin` or a trusted script.
## CLI, Embassy Admin, and direct DB inspection each fit different work
### Better fits for the CLI
- fast local inspection
- checking whether a service actually wrote the expected tables
- inspecting models, schema, and rows
- one-off debugging without writing code first
### Better fits for `Embassy Admin`
- backend-owned actions
- automated token issuance
- formal management workflows
- internal admin surfaces
### Better fits for direct database inspection
- when the issue has clearly reached the lower database layer
- when the CLI already narrowed the problem but you need deeper SQL-level facts
## Common CLI debugging actions
```bash
city
fed
city federation status
city agent list
```
## CLI and Desktop City ownership
The CLI and Desktop share the same `~/.downcity` data while owning independent City processes.
Each City registers its host at startup. If the other host is already running, the new host asks
whether it should close the existing City and take over. Cancelling leaves the existing host
untouched. Stopping City does not remove Agent, Power, or Session data.
## User system dashboard
Open `fed`, choose a saved Federation, then select `User system dashboard` in the Admin menu.
The CLI reads admin data from the installed official services and computes the dashboard locally in the terminal.
The dashboard covers:
- `accounts`: registered users, new users, active sessions
- `usage`: active users and service-call events
- `payment`: paying users, first-paid users, paid orders, real revenue by currency
- `balance`: credited credits and current balance credits
If a service is not installed, the dashboard marks that module as unavailable and still shows data from the other installed services.
Revenue uses successful payment records and their real `amount_minor` values. Credits are shown separately as balance and recharge units.
## A common debugging chain
1. confirm Federation is connected to the database you think it is
2. confirm the expected models and tables exist
3. confirm the service actually wrote data
4. only then suspect deeper business logic
## Example: usage seems missing
A good first reaction is not "change code" but "confirm facts":
- is the usage service enabled?
- was the usage table created?
- is this really the database instance you expect?
- were any events written at all?
## Example: payment completed but access is still denied
Check these facts first:
- did the webhook reach Federation?
- was the webhook event persisted?
- did the entitlement become active?
## Common misunderstandings
### The CLI is not just a one-time development tool
If you operate many products, services, or model configurations, it remains a frequent operations entry.
### `Embassy Admin` is not just a more advanced CLI
They are different modes:
- `Embassy Admin`: trusted programmatic management
- CLI: human terminal inspection
### Direct SQL should not always be your first instinct
In many cases the CLI is already enough to narrow the problem sharply.
## Read next
- For the basic `fed` / `city` entries, read [CLI Quickstart](/en/city-sdk-docs/packages/cli/quickstart)
- For model and table inspection, read [Models and Tables](/en/city-sdk-docs/packages/cli/models-and-tables)
- For the data layer behind those facts, read [Store and Table](/en/city-sdk-docs/packages/federation/store-and-table)
### @downcity/agent
Source: https://downcity.ai/en/city-sdk-docs/packages/agent/
`@downcity/agent` provides Agent, AgentSessions, Session, and the execution runtime. `@downcity/city` provides RemoteAgent and
City. Agents do not own Powers. City owns unique Power instances, configuration projection, and lifecycle;
Power author contracts live in `@downcity/city/power`. Workspace resources come from
`@downcity/city`.
```ts
const city = new City({ workspaces: [workspace] });
const agent = new Agent({ id: "assistant" });
city.agents.add(agent);
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Hello" });
const result = await turn.finished;
```
Provide Power instances through `CityOptions.powers` or `city.powers.add()` before use.
`city.agents.add()` only registers an Agent; every City Power becomes available automatically.
## Read file changes for each turn
A Session bound to a Workspace persists file edits successfully committed by the built-in
structured `write` and `edit` tools in the final Assistant Message. The diff comes directly from
each atomic file-tool mutation instead of scanning the worktree before and after a turn, so changes
made by shell commands, scripts, external editors, or other Sessions are excluded. Git is not
required.
The `data-session-turn-file-diff` data part contains per-file status, line counts, and unified diff.
Use the exported parser to read it safely:
```ts
import { read_session_turn_file_diff_data } from "@downcity/agent/session";
const page = await session.messages();
for (const message of page.items) {
if (message.type !== "assistant") continue;
for (const part of message.parts) {
if (part.type !== "data") continue;
const file_diff = read_session_turn_file_diff_data(part);
if (file_diff) console.log(file_diff.files, file_diff.additions, file_diff.deletions);
}
}
```
## Group and GroupSession
`Group` is a collaborative subject with its own model, members, dispatch strategy, and
`GroupSession` collection. A GroupSession persists shared messages and dispatch state under
`groups//sessions//`.
After the first user message is persisted, the GroupSession uses `Group.model` to generate a
short title asynchronously. The title is canonical metadata stored in `meta.json`; it is separate
from `preview_text`, which remains the latest-message preview. Later messages do not replace the
title. Subscribe to `{ type: "title", title }` for live title changes, or call
`group_session.rename(title)` to persist a manual title and prevent background generation from
overwriting it.
```ts
const group_session = await group.sessions.create({ workspace });
group_session.subscribe((event) => {
if (event.type === "title") console.log(event.title);
});
await group_session.prompt({ query: "Review the login flow" });
await group_session.rename("Login flow review");
const sessions = await group.sessions.list();
console.log(sessions[0].title, sessions[0].preview_text);
```
### @downcity/city/workspace
Source: https://downcity.ai/en/city-sdk-docs/packages/workspace/
`@downcity/city/workspace` provides the Workspace implementation used by an Agent; common
constructors are also exported from `@downcity/city`. A Workspace owns the
project path, rooted file access, file and search tools, environment variables, and an optional Shell.
It does not own Agent identity, Powers, Sessions, storage, or model calls.
```bash
pnpm add @downcity/city @downcity/sandbox-microsandbox
```
```ts
import { Agent, City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
env: { NODE_ENV: "development" },
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
});
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
const session = await agent.sessions.create({ workspace });
```
## Ownership
- `Workspace` owns project resources and the optional Shell.
- `Shell` owns its Sandbox Provider, persistent Workspace Sandbox, and Shell Sessions.
- `City` owns registered Workspaces without understanding their execution backend.
- `Agent` owns identity, instruction, model, custom tools, and Sessions; `City` owns Powers.
- `AgentSessions` owns Sessions. A Workspace is only a resource that a Session may use.
- Shell is a Workspace capability. There is no separate `@downcity/shell` package.
An Agent can create Sessions with multiple Workspace resources. A Workspace may be registered in a
City or managed by the host outside City. Agent-private Sessions and City-allocated Agent/Power
execution data remain isolated.
After an atomic file commit, the structured `write` and `edit` tools report a
`workspace.file_mutation` through `WorkspaceToolActionResult.effects`. Effects are not sent to the
model as Tool output. The Agent Session collects them only for the current Turn and projects file
mutations into an Assistant data part when the Turn closes. Shell and external edits do not produce
this effect.
## Storage
City provides a domain-neutral Storage Provider. The default is in-memory; a host can inject a local
file provider when persistence is needed. With a local provider, runtime state is centralized outside
project directories:
```text
~/.downcity/agents//
├── sessions/
└── powers/
```
`StorageProvider` is intentionally domain-neutral. Agent runtime interprets Session data, while the
City Power runtime interprets Power data. Workspace does not own or create this storage. Project
File/Search tools remain rooted at the real project path and cannot read this private state.
Edge adapters should import `WorkspaceRuntime` and protocol types from `@downcity/type/workspace`
so they do not load Node.js filesystem, PTY, or local Shell implementations.
### AIChannel and Provider Adapters
Source: https://downcity.ai/en/city-sdk-docs/service/ai/provider/
`AIChannel` owns only Federation's upstream execution boundary. It accepts `AIChannelStreamInput` and returns a `ReadableStream`. Fallback, billing, Sessions, and tool execution do not belong to the Channel.
## OpenAI-compatible upstream
```ts
import {
AIChannel,
AIService,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const channel = new DeepSeekChannel();
const ai = new AIService();
ai.use(channel.model({
id: "deepseek-v4-flash",
upstream_model: "deepseek-chat",
name: "DeepSeek V4 Flash",
context_window: 128_000,
}));
federation.use(ai);
```
`stream_openai_compatible_model()` is a boundary adapter. It maps `ModelCall` to Chat Completions and converts upstream SSE into Downcity events. A private vendor protocol should provide its own adapter with the same input and output contract.
## In-process ModelClient
For local Agent use without Federation:
```ts
const model = create_openai_compatible_model({
id: "deepseek-chat",
upstream_model: "deepseek-chat",
base_url: "https://api.deepseek.com/v1",
api_key: process.env.DEEPSEEK_API_KEY!,
});
```
## Reasoning and fallback
```ts
ai.use(channel.model({
id: "text-model",
upstream_model: "vendor-text-model",
name: "Text Model",
reasoning: {
efforts: [{ id: "high", name: "High" }],
default_effort: "high",
},
fallback: [{
match: (file) => file.media_type.startsWith("image/"),
model_id: "vision-model",
}],
}));
```
AIService passes only files from the latest user message to fallback rules. After selecting the final model, it validates reasoning and preserves that latest user message unchanged. Historical files matched by the final model's fallback rules become filename, original URL, or MIME type text before the call reaches the target Channel.
Channel and model `provider_options` are merged only on the server and interpreted by the selected Adapter. They are not part of the public model protocol.
## Decision models (System One)
A model that returns typed decisions instead of generated text implements `decision()` instead of `stream()`:
```ts
import {
AIChannel,
AIService,
type AIChannelActionInput,
type AIChargedResult,
type AIDecisionResult,
} from "@downcity/federation";
class JevChannel extends AIChannel {
constructor() {
super({ id: "typesafe", env: { TYPESAFE_API_KEY: "TypeSafe API Key" } });
}
async decision(input: AIChannelActionInput): Promise> {
const api_key = input.env("TYPESAFE_API_KEY");
if (!api_key) throw new Error("TYPESAFE_API_KEY is required");
return { output: await call_jev(api_key, input.input) };
}
}
const ai = new AIService();
ai.use(new JevChannel().model({
id: "jev-1.13",
upstream_model: "typesafe/jev-1.13",
name: "Jev",
pricing: { currency: "USD", unit: "request", rates: { request: 0.0004 } },
}));
federation.use(ai);
```
Federation exposes `POST /v1/ai/decision` automatically and validates the request before the call reaches the Channel. The catalog reports `model_type: "decision"` with `modalities: ["decision"]`, and because the model does not implement `stream()`, it is not offered as an Agent conversation model.
Metering and billing reuse the same path as every other capability: return `usage` from the Channel to record usage, and declare `pricing` to charge per request.
### OpenAI Chat Completions
Source: https://downcity.ai/en/city-sdk-docs/service/ai/chat-completions/
`POST /v1/ai/chat/completions` is an adapter around Downcity Model Protocol:
```text
OpenAI request
-> ModelCall
-> AIService / AIChannel
-> ModelStreamEvent
-> OpenAI JSON or SSE
```
```bash
curl "$FEDERATION_URL/v1/ai/chat/completions" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role":"user","content":"Hello"}],
"stream": true
}'
```
Messages, tools, tool choice, images, reasoning, usage, and finish reasons are converted into the same Downcity execution path. The endpoint is not a raw passthrough.
### Model relay and API keys
Source: https://downcity.ai/en/city-sdk-docs/service/ai/relay/
A Federation with model relay enabled exposes `GET /v1/models` and `POST /v1/chat/completions`. Set your OpenAI client's Base URL to your Federation URL plus `/v1`. Streaming, non-streaming, tools, and usage share the existing AIService execution and settlement pipeline.
## Manage your keys
Website users can manage their own keys in [Account → API Keys](/account?view=keys); the full secret appears only in the successful creation dialog. SDK integrations use the account's login `user_token` to manage keys. A calling key cannot access account, payment, key management, or request history APIs.
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://your-federation.example",
user_token,
});
const issued = await embassy.user.relay.create_key({
name: "Editor",
allowed_models: ["deepseek-v4-flash"],
spend_limit: 10000,
expires_at: null,
});
// Store issued.api_key securely in your calling client; it cannot be retrieved again.
const keys = await embassy.user.relay.list_keys();
await embassy.user.relay.update_key({ key_id: issued.key.key_id, status: "disabled" });
await embassy.user.relay.update_key({ key_id: issued.key.key_id, status: "active" });
await embassy.user.relay.revoke_key(issued.key.key_id);
```
`allowed_models` contains public model or route IDs. `[]` permits no inference; `["*"]` permits all published entries. Access to a route does not grant direct access to its model. Names, permissions, limits, and expiration can be updated. Disabled keys can be reactivated; revoked keys cannot. Disabling or revoking a key blocks new requests, while in-flight requests still settle.
`spend_limit` is measured in Credits; `null` means no key spending limit. Before inference, the service checks account balance and the key's **successfully settled** spending. It does not reserve funds. Concurrent in-flight requests can exceed the configured amount, so this is a soft limit. A key has no separate wallet: charges use the account's existing Credits ledger. Changing the limit does not reset settled lifetime spending; zero blocks new calls. The website shows the actual limit and does not infer used spending when the backend does not provide it.
The key list shows name, status and spending limit. Open a key's name for its permissions, expiration and creation time; use the right-hand actions to edit, disable, reactivate or revoke it. Copy and store a newly issued key before closing its one-time dialog. Never share it or include it in public code.
## Use an OpenAI client
Website [Models & setup](/account?view=models) provides the live catalog and backend-declared pricing. Open **Set up** for the selected model's capabilities, full pricing conditions, public model ID and configuration-derived Base URL. Copy those values into your client. The page never reads a real key; catalog visibility does not grant your key access.
Prices preserve the backend currency, quantity, unit, rate components and applicability dimensions. Multi-component or conditional pricing is not a single flat rate, and missing pricing does not mean free. No Credits conversion or discount is inferred. Unknown context or reasoning options remain undeclared; tools and vision support must not be inferred from model names or tags.
Set `FEDERATION_URL` to your service address and `DOWNCITY_API_KEY` to your securely stored calling key. Replace the example model ID below with the model or route ID copied from the catalog.
```bash
curl "$FEDERATION_URL/v1/models" \
-H "Authorization: Bearer $DOWNCITY_API_KEY"
curl "$FEDERATION_URL/v1/chat/completions" \
-H "Authorization: Bearer $DOWNCITY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Hello"}],"stream":true}'
```
Chat Completions supports streaming and non-streaming language calls. Responses and Anthropic Messages are not supported. Native `POST /v1/ai/stream` also accepts calling keys, using the Downcity Model Protocol's `model_id` and `call`:
```bash
curl "$FEDERATION_URL/v1/ai/stream" \
-H "Authorization: Bearer $DOWNCITY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"protocol_version":1,"model_id":"deepseek-v4-flash","call":{"messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}}'
```
The native endpoint returns Downcity model-event SSE, not OpenAI SSE; parse it with a client that implements the native protocol. With relay enabled, language endpoints preserve the requested model's capabilities and selling price. They do not automatically switch to another model for new media input. Explicitly select a capable model when needed.
## Inspect requests and spending
Website [Request history](/account?view=requests) offers your own request pages, key filtering and safe attempt details. The list shows model, execution status, charge and time. Open a model name to inspect the key, request ID, token measurements, duration, settlement and upstream attempts. **Filters** narrows the list by key; refresh and cursor pagination keep the existing query behavior. Unknown metering and unsettled charges remain unknown rather than zero.
```ts
const page = await embassy.user.relay.list_requests({ limit: 50 });
const record = page.items[0];
if (record) {
const attempts = await embassy.user.relay.list_attempts(record.request_id);
const next_page = await embassy.user.relay.list_requests({ before: record.request_id });
}
```
The `x-request-id` response header identifies one logical request; every upstream attempt has its own `attempt_id`. History includes key ownership, model, channel, tokens, duration, and settlement status. Attempts include time to the first content event. `charged_credits` is available only after settlement reaches `completed`. Failed attempts, missing usage, and pending settlement must not be interpreted as zero spending. Logs do not store prompts, responses, calling keys, or provider credentials. Execution, metering and settlement are recorded separately. Requests rejected before upstream execution may have no history record. First-content latency is measured from the logical request start; unknown outcomes do not trigger automatic regeneration.
## Enable relay in your Federation
```ts
const ai = new AIService({
relay: true,
credits: {
precheck: (user_id, needed_credits) => credits.precheck(user_id, needed_credits),
charge: (input) => credits.charge(input),
},
}).use(models);
federation.use(ai);
```
The first startup imports registered language models into the persistent catalog, retaining their adapters and billing functions. Publish subsequent templates through the management SDK. Public capabilities, reasoning options, and selling prices come from registered templates; operators configure availability, channels, upstream mappings, priorities, weights, and routes. The website provides personal key management. Administrators can use Fedman's Channels & routes page to manage public models, channels, deployments and routes, or use the admin SDK below.
```ts
const admin = new Embassy({ federation_url, admin_token });
const metadata = await admin.admin.relay.metadata(); // Installed templates and adapters, no credentials
const health = await admin.admin.relay.health(); // Shared cooldown snapshot, not an active probe
const snapshot = await admin.admin.relay.catalog();
// Edit snapshot.catalog, then submit the revision that was read.
await admin.admin.relay.configure(snapshot.catalog, snapshot.revision);
```
Fedman derives form choices from the live catalog and `metadata()`. Create, edit, enable/disable and confirmed deletion use the existing catalog contract. References block deletion; there is no implicit cascade. Saves include the read revision; after a 409 conflict, reload the latest catalog and edit again. Registered channels must be installed and retain fixed runtime mappings. OpenAI channels reference server environment variable names; do not enter secret values. Deployment status shows shared cooldowns only. No cooldown record does not imply healthy service; no paid active probe is provided.
The catalog separates Model, Channel, Deployment, and Route. A Model references a capability and price template. A Channel selects the `registered` or `openai_chat_completions` adapter. A Deployment maps a public model to a channel and upstream model. A Route chooses deployments of the same public model. Standard OpenAI channels use a credential-free HTTPS API root as `base_url` and a Federation environment variable name as `credential_env`; catalog APIs never return credential values. New channels must support the template's declared capabilities.
The default `priority_weighted` strategy chooses the highest `priority` tier, then samples positive integer `weight` values. Direct model calls permit at most two attempts; a route's `max_attempts` is 1–3. Explicit 401, 403, and 429 rejections allow failover. Network errors, 5xx responses, timeouts, and errors after receiving an upstream stream do not trigger automatic retries. Shared database cooldowns honor `Retry-After` for 429 responses, capped at five minutes; other failures use short cooldowns. Expired cooldowns allow normal traffic to probe recovery. Channel `timeout_ms` defaults to 120000 and accepts 1000–300000; adapters must honor the cancellation signal.
Catalog snapshots are cached for five seconds per instance. Updates require `revision` to prevent lost writes; other instances see changes within one cache interval. Routing adds no model invocation. Key state is read on every request, while settled spending and account balance checks run in parallel. Requests and attempts are persisted before upstream execution, and shared health is read from the database. Measure the resulting latency in your deployment; it depends on database placement and upstream behavior.
RPM, TPM, global concurrency leases, Responses, Anthropic Messages, and OAuth account pools are not provided yet.
### Call a model
Source: https://downcity.ai/en/city-sdk-docs/service/ai/calling/
## Call a Federation model
```ts
const catalog = await city.ai.catalog();
const model = catalog.require("deepseek-v4-flash");
const stream = await model.stream({
messages: [{
role: "user",
content: [{ type: "text", text: "Explain this repository" }],
}],
});
for await (const event of stream) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
```
`model.stream()` returns a `ReadableStream`. A successful stream begins with `model_start` and provides final `model_usage` before `model_finish`. Invalid event ordering fails as a protocol error.
## Use a model with Agent
Objects returned by the catalog implement `ModelClient` and can be passed directly to Agent:
```ts
const catalog = await city.ai.catalog();
const model = catalog.require("deepseek-v4-flash");
const agent = new Agent({ id: "reviewer", model });
```
Agent owns Session context, tool loops, and approvals. Federation owns final model selection and billing.
## Multimodal fallback
Fallback checks file content only in the latest `role: "user"` message in `ModelCall.messages`. A new text-only user message does not trigger fallback again. When the final model's fallback rules reject a historical file, the execution view replaces that file with its `filename`, original URL, or MIME type as plain text. The original Session file remains unchanged. Trailing assistant or tool messages do not change which user message is inspected.
## OpenAI-compatible clients
Use `/v1/ai/chat/completions` for the OpenAI SDK, curl, or an existing gateway. The boundary adapter converts that request into the same `ModelCall`, so routing, fallback, reasoning, usage, and billing stay consistent.
## Call a decision model
A decision model takes raw state and typed questions, and returns answers with calibrated probabilities instead of generated text:
```ts
/** Call Jev through the current Federation user context. */
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "http://localhost:3000",
user_token: process.env.FEDERATION_USER_TOKEN,
});
const { answers } = await embassy.user.ai.decision({
model: "jev-latest",
state: { type: "text", text: "The customer has failed to connect Stripe for three days." },
questions: [
{ id: "urgent", type: "boolean", instructions: "Does this message express urgency?" },
{ id: "route", type: "choice", instructions: "Which queue should handle this?", options: ["billing", "support"] },
{ id: "churn_risk", type: "score", instructions: "Churn risk", min: 1, max: 4 },
],
});
answers[0].value; // true
answers[0].probability; // 0.87
answers[1].distribution; // { billing: 0.72, support: 0.28 }
```
The official edge-worker and local-node both provide `jev-latest` through B.AI's official `https://api.b.ai/v1/decisions` endpoint. Configure `B_API_KEY` in the Federation's Global / Env to enable `choice`, `score`, and `boolean` decisions. Jev is also available in the Desktop Group dispatch model selector.
The official edge-worker uses Downcity SDK 1.5.0. Its model catalog exposes structured `pricing` with currency, billing units, rates, and applicable conditions.
Jev accepts 2–255 choice options. Score ranges must map to 2–10 levels (`round(max - min) + 1`), and boolean results use a 0.5 probability threshold. JSON state cannot be an empty object or array.
`state` is either `{ type: "text", text }` or `{ type: "json", data }`. Three question types are supported, and none of them produces free text:
| Type | Answer | Notes |
| --- | --- | --- |
| `boolean` | `true` / `false` | — |
| `choice` | one of `options` | 1 to 255 unique options |
| `score` | a number | requires `min < max` |
Question ids must be unique and every question must be answered exactly once. Invalid input fails with 422 before any usage is recorded, and a Channel response that does not cover every question is rejected too.
`embassy.user.ai.decision(input, signal)` accepts an optional `AbortSignal`. Use `question_id` to associate each answer with its question. Group can bind this method as its decision client to select parallel responders each round; members keep their own execution models.
Decision models are not conversation models: they carry `modalities: ["decision"]` and cannot be passed to an Agent.
### Feedback Service
Source: https://downcity.ai/en/city-sdk-docs/service/feedback/
`FeedbackService` adds a simple feedback inbox to a Federation. Signed-in users can submit feedback for the Bureau bound to their Token, admins can review and reply, and users can later read the official reply through the API.
## Register
```ts
import { Federation } from "@downcity/federation";
import { FeedbackService } from "@downcity/services";
const fed = new Federation({ database });
fed.use(new FeedbackService());
```
The service stores feedback in Downcity's database. It does not send email, Slack messages, webhooks, or push notifications.
## User APIs
Submit feedback with a user token:
```bash
curl http://127.0.0.1:43127/v1/feedback/send \
-H "Authorization: Bearer ub_xxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Image generation often stays queued",
"contact": "user@example.com",
"meta": {
"page": "/billing",
"client_version": "1.2.3"
}
}'
```
Response:
```json
{
"feedback_id": "fb_xxx",
"status": "open",
"created_at": "2026-06-30T10:00:00.000Z"
}
```
Read the current user's feedback for the Bureau bound to the Token:
```bash
curl "http://127.0.0.1:43127/v1/feedback/me?status=replied&limit=50" \
-H "Authorization: Bearer ub_xxx"
```
The response includes the original message, current status, admin reply, reply author, and reply timestamp.
## Admin APIs
List feedback:
```bash
curl "http://127.0.0.1:43127/v1/feedback/messages?status=open&limit=100" \
-H "Authorization: Bearer sk_admin_xxx"
```
Optional filters are `bureau_id`, `user_id`, `status`, and `limit`.
Reply to feedback:
```bash
curl http://127.0.0.1:43127/v1/feedback/reply \
-H "Authorization: Bearer sk_admin_xxx" \
-H "Content-Type: application/json" \
-d '{
"feedback_id": "fb_xxx",
"reply": "We fixed the async result parser.",
"reply_by": "support"
}'
```
Update status:
```bash
curl http://127.0.0.1:43127/v1/feedback/status \
-H "Authorization: Bearer sk_admin_xxx" \
-H "Content-Type: application/json" \
-d '{
"feedback_id": "fb_xxx",
"status": "closed"
}'
```
Supported statuses are `open`, `reviewing`, `replied`, and `closed`.
### Production Checklist
Source: https://downcity.ai/en/city-sdk-docs/operations/production-checklist/
Before launch, confirm at least the following:
## Security
- Administrator IDs, passwords, and session tokens exist only in trusted environments.
- Clients only receive `user_token`, not provider keys.
- `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` is configured in City.
- Admin env endpoints only accept trusted requests.
## Stability
- Every service that should be exposed has been registered through `base.use(service)`.
- AI models have been registered through `AIChannel.model()` + `AIService.use()` and include the required capabilities.
- Critical provider env values have been configured.
- The city table uses an explicit database URL.
- before hooks block over-quota users.
- after hooks record usage and error clues.
## Multi-city setup
- `bureau_id` clearly separates cities.
- Usage records include `bureau_id`, `user_id`, and `model`.
- Different cities have default quota strategies.
## Rollback
- Provider keys can be rotated quickly.
- Model definitions can be paused or replaced quickly through code deployment.
- Service errors are captured by `onError` or your logging system.
### Deploy to Cloudflare Workers
Source: https://downcity.ai/en/city-sdk-docs/operations/deploy-cloudflare-workers/
This page is the operational path for deploying a Federation project to Cloudflare Workers.
If you only want to understand how Federation is wired to Workers, read [Cloudflare Workers](/en/city-sdk-docs/guides/cloudflare-workers) first. This page focuses on production steps: `federation.json`, local project creation, local deploys, D1 / Queue / R2, interactive administrator setup, env setup, OAuth callbacks, and health checks.
## PostgreSQL and Supabase
Workers can keep Queues and R2 while connecting to managed PostgreSQL through Hyperdrive. Use `@downcity/database-postgresql`; existing Federation Accounts, Credits, Payment and AI services remain in place. Supabase Auth and Storage are not required.
Create and dispose PostgreSQL clients within one Worker invocation. Do not reuse sockets across requests. Streaming responses keep their client alive until completion or cancellation and registered background work has finished. Hyperdrive pools upstream connections; disable query caching for authorization and financial queries. Store connection credentials in secrets or local environment variables.
For a D1 migration, preserve all source tables, including historical tables. Pause every writer, queue consumer and scheduled processor before taking a consistent export. Run `prepare_schema()` on empty PostgreSQL to create structures only; import and verify the original identities, keys, accounts and ledgers before `prepare_database()` establishes constraints and runtime readiness. Never publish a production entry pointing to an empty target.
Archive snapshots and per-table content hashes verify preservation; archives do not replace usable application tables. Once writes switch to PostgreSQL, rollback requires reconciliation and transferring new writes back.
## Prerequisites
- A Cloudflare account
- Dependencies installed in the project
Downcity does not deploy Workers automatically after `git push`. Deployment is explicit:
```bash
fed deploy
```
## 1. Create a Federation project
Developers normally start from an empty directory:
```bash
mkdir my-city
cd my-city
fed create . --template cloudflare-workers
```
`fed create . --template cloudflare-workers` asks for the Federation name and generates the Worker project skeleton:
- `federation.json`
- `src/index.ts`
- `package.json`
- `tsconfig.json`
The generated `federation.json` only stores basic project information:
```json
{
"schema": 1,
"type": "federation",
"id": "fed_example",
"name": "my-city",
"entry": "src/index.ts",
"deployment": {
"target": "cloudflare-workers",
"resources": {
"d1": {
"type": "d1",
"binding": "DB",
"name": "my-city-db"
},
"queue": {
"type": "queue",
"binding": "DOWNCITY_QUEUE",
"name": "my-city-queue"
},
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "my-city-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
`id` is the path-independent Fed identity, and `deployment.target` declares where it deploys. `entry` and `deployment.resources` are commit-safe deployment intent; D1 ids, account state, Worker URLs, and Wrangler config are handled by `fed deploy`.
If the City template already lives in a Git repository, use `fed create` to bring it local first:
```bash
fed create my-city --template https://github.com/example/my-city.git
cd my-city
```
`fed deploy` does not deploy remote URLs directly. It only deploys local Federation projects, so `.env` and deploy results have a clear project owner.
## 2. Deploy the current directory
Inside the Federation project directory:
```bash
fed deploy
```
When `federation.json` uses `deployment.target: "cloudflare-workers"`, `fed deploy` checks Cloudflare auth automatically. Account state is stored in the system-level Federation registry and reused by later deploys.
You can also pass a local directory:
```bash
fed deploy ./my-city
```
## 3. Resource Config
Cloudflare Workers currently supports three resource declarations:
```json
{
"deployment": {
"target": "cloudflare-workers",
"resources": {
"d1": {
"type": "d1",
"binding": "DB",
"name": "my-city-db"
},
"queue": {
"type": "queue",
"binding": "DOWNCITY_QUEUE",
"name": "my-city-queue"
},
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "my-city-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
If D1 / Queue are omitted, `fed deploy` uses `${name}-db` and `${name}-queue`. Storage is enabled only when `deployment.resources.storage` exists.
During deployment, `fed deploy` reuses same-name D1 databases, Queues, and R2 buckets. If a declared resource is missing and this is not `--dry-run`, it creates the resource automatically. The D1 database id is Cloudflare deploy state and is not written back to project config.
Cloudflare account is not part of the Federation project config. `fed deploy` reuses Wrangler login first; only when Cloudflare cannot resolve the account automatically does the CLI prompt once and save it into local fed CLI state.
You can also pass the account id for one deploy:
```bash
fed deploy --account-id
```
## 4. Dry-run
Before publishing, check the bundle and Wrangler config:
```bash
fed deploy --dry-run
```
For another directory:
```bash
fed deploy ./my-city --dry-run
```
## 5. Deploy flow
`fed deploy` runs these steps:
- reads `federation.json` from the target directory
- checks the target platform identity from `federation.json.deployment.target`; Cloudflare Workers opens Wrangler login or guides account selection when needed
- if this is not `--dry-run`, automatically bumps `package.json.version` by one patch version before deployment
- runs `pnpm build` if package.json has a `build` script
- runs `pnpm typecheck` if package.json has a `typecheck` script
- prepares D1 by looking up `deployment.resources.d1.name`, and creates it automatically when missing
- prepares Queue by looking up `deployment.resources.queue.name`, and creates it automatically when missing
- if `deployment.resources.storage` is declared, prepares the corresponding R2 bucket and creates it automatically when missing
- generates Wrangler config temporarily instead of writing it into the project directory
- builds and loads the Worker's `prepare_database(env)` export, using the project's Wrangler `getPlatformProxy` to connect D1 to the exact remote database; schema, bootstrap, and service preparation must succeed before upload
- interactively asks for administrator credentials on first initialization or when `--admin-reset` is used, then writes the password digest through deployment-authorized D1 access
- runs `wrangler deploy --config `
- requests Worker `/health` to check runtime readiness and verifies `/v1/admin/login` when new credentials were applied
- registers the Worker URL, deploy state, administrator ID, and time-limited Session Token in the local Federation registry without changing the active Federation
- requests Worker `/health` when `--verify` is passed
Deploy output is grouped by stage: `Project`, `Version`, `Build`, `Typecheck`, `D1 Database`, `Queue`, `Storage`, `Wrangler`, `Deployment`, and `Federation Registry`.
downfed has no default Federation. A deployed instance appears in the downfed list, but `active_server_url` is written only when the user explicitly opens that Federation.
`federation.json` stays as the commit-safe project declaration. Business secrets, provider keys, and service env should be written into Federation's own env table instead of `federation.json`.
`fed deploy --dry-run` and `fed deploy --verify-only` do not change the project version.
### Worker database preparation entry
Provide a named export. Share service registration with the request runtime, while creating a fresh instance for preparation:
```ts
function create_federation(env: Env): Federation {
const fed = new Federation({
database: new Database({ binding: env.DB }),
database_initialization: "prepared",
});
// Register the same services used by the request runtime here.
return fed;
}
export async function prepare_database(env: Env): Promise {
const fed = create_federation(env);
await fed.prepare_database();
}
```
The CLI loads the entry in a Node subprocess with the project's installed Wrangler, its esbuild, and project dependencies. Install a Wrangler version supporting remote D1 bindings. The entry and its imports must be loadable in Node; avoid Worker-only globals or database operations at module scope. The temporary bundle lives inside the project and is removed on completion or failure; the proxy is always disposed. Local `.env` files are excluded from the preparation proxy. D1 uses the generated configuration's exact database ID and `remote = true`; Queue/R2 remain local proxies. Preparation should only perform database work.
`--dry-run` builds, loads, and checks the export without calling it or opening a remote D1 proxy. `--verify-only` skips preparation. `--skip-build` still prepares the database. Projects overriding `deployment.scripts.deploy` must arrange preparation themselves. Existing projects without the export fail deployment and must update their Worker and installed SDK packages. Run `maintenance()` periodically rather than on each cold start.
Preparation and administrator writes happen before upload and are not rolled back if upload fails; `--admin-reset` may already have revoked old administrator sessions. Historical SQL migration files are not automatically scanned or applied.
## 6. Developer edge starter
The Downcity repository keeps `templates/edgefed` as a developer-friendly starter project:
```bash
fed deploy templates/edgefed --verify
```
This starter is useful for local experiments and custom deployments. Downcity's private official production Worker implementation lives outside the public repository.
## 7. Initialize Federation
Call `/health` once after deployment. This lets the Worker boot Federation and create the built-in `env` / `cities` tables.
```bash
curl https://my-city..workers.dev/health
```
Expected shape:
```json
{
"ok": true,
"services": ["env", "cities", "bureaus"]
}
```
## 8. Administrator login
On the first `fed deploy`, Downcity asks for an administrator ID, password, and confirmation. Passwords must be at least 12 characters; only a PBKDF2 digest is stored in D1. To replace lost credentials, run `fed deploy --admin-reset`; this requires deployment authority, revokes all existing administrator sessions, and asks for new credentials. `--yes` confirms the destructive reset in non-interactive environments but does not skip credential prompts.
After deployment, `fed web`, the admin workspace, and `fed query` authenticate through `/v1/admin/login` and use a time-limited administrator Session Token. The browser never receives the remote Session Token; it remains in the local CLI BFF or registry.
## 9. Write provider and service env
Provider keys and service secrets should be written into the Federation env table instead of public clients.
Example:
```bash
curl -X POST https://my-city..workers.dev/v1/env/upsert \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"key":"DEEPSEEK_API_KEY","value":"sk-..."}'
```
Common keys:
| Key | Purpose |
| --- | --- |
| `DEEPSEEK_API_KEY` | Provider key used by the edge starter project |
| `STRIPE_SECRET_KEY` | Stripe API key for Checkout creation |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `DOWNCITY_CITY_BASE_URL` | Public City base URL used to derive payment result pages |
| `CREEM_API_KEY` | Creem API key for Checkout creation |
| `CREEM_PRODUCT_ID` | One-time, tax-inclusive Creem product used for hosted Checkout |
| `CREEM_CURRENCY` | Creem product currency; it must match the product's actual currency |
| `CREEM_WEBHOOK_SECRET` | Creem webhook signing secret; the provider is disabled when missing |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Optional Google OAuth login |
| `WECHAT_CLIENT_ID` / `WECHAT_CLIENT_SECRET` | Optional WeChat website login |
## 10. Configure OAuth callbacks
If you enable OAuth login, configure the provider callback URL to the deployed Worker:
```text
https://my-city..workers.dev/v1/accounts/oauth/callback
```
The callback domain must match the public domain users actually visit. If you later move to a custom domain, update the OAuth provider configuration as well.
## 11. Verify
If a Federation is already connected, run only the health check:
```bash
fed deploy --verify-only
```
You can also check manually:
```bash
curl https://my-city..workers.dev/
curl https://my-city..workers.dev/health
```
## Updating the deployment
After code changes:
```bash
fed deploy --verify
```
Runtime env lives in D1, so redeploying the Worker does not erase provider keys, OAuth secrets, cities, balances, usage records, or Stripe payment records.
### Deploy With Dokploy
Source: https://downcity.ai/en/city-sdk-docs/operations/deploy-dokploy/
`templates/localfed` defaults to local development. The Compose deployment disables Local Account so its credential-free local login cannot be exposed publicly, and stores SQLite data in a persistent `/data` volume.
## Dokploy Settings
Create an app in Dokploy from this Git repository:
| Setting | Value |
| --- | --- |
| Deployment mode | Compose |
| Compose file | `./docker-compose.yml` |
| Container port | `43127` |
| Volume | Compose creates `downcity_infra_data` |
`HOST`, `PORT`, the database path, and the login mode are already defined in `docker-compose.yml`:
```txt
HOST=0.0.0.0
PORT=43127
DATABASE_PATH=/data/downcity.sqlite
LOCAL_LOGIN=false
```
`/data` must stay persistent. Cities, accounts, and Federation env entries are stored in that SQLite database.
## Domain Path
The Node block listens on `43127`, and the Federation API is mounted at the service root:
```txt
GET /health
POST /v1/env/upsert
GET /v1/services
POST /v1/ai/stream
```
If your domain is `https://base.example.com`, product-side SDKs and trusted backends should use:
```txt
base_url=https://base.example.com
```
## Provider Keys
The `localfed` template reads `DEEPSEEK_API_KEY` directly from the process environment. Configure it as a Dokploy secret:
```txt
DEEPSEEK_API_KEY=sk-...
```
## Verify
After Dokploy finishes the deployment, verify the public service:
```bash
curl https://base.example.com/health
```
When using an IP and port:
```bash
curl http://1.2.3.4:43127/health
```
Expected shape:
```json
{
"ok": true,
"services": ["env", "cities", "bureaus", "accounts", "ai"]
}
```
If the request times out, the Dokploy service is not exposed publicly, the VPS firewall does not allow `43127`, or a reachable domain has not been bound yet.
## Call From Embassy
Product frontends and apps call Federation through Embassy:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://base.example.com",
user_token: "user_token",
});
```
Trusted backends use the Embassy Admin domain with an administrator session token:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://base.example.com",
admin_token: administrator_session_token,
});
```
See [Embassy User](/en/city-sdk-docs/reference/city-user) and [Embassy Admin](/en/city-sdk-docs/reference/city-admin) for the full SDK surface.
### Environment and Key Rotation
Source: https://downcity.ai/en/city-sdk-docs/operations/env-rotation/
The main benefit of managing keys in the Federation is that many products do not need separate config updates.
## provider keys
Provider keys can be managed through Runtime env and saved into the Federation database:
```ts
await admin.env.upsert({
key: "OPENAI_API_KEY",
value: "sk_new",
});
```
After rotation, every service handler connected to this runtime reads the new value. Env values in the database take priority over `.env`.
## Downcity's own keys
Downcity maintains two critical keys of its own:
- `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY`
### admin secret
`Embassy Admin` uses a time-limited administrator session token obtained from `/v1/admin/login`.
### token signing key
`DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` is used internally by Federation to issue and validate `user_token`.
## What to watch during rotation
- Rotating a provider key usually does not affect existing `user_token`
- Rotating `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` invalidates old tokens
- If you rotate the signing key in production, shorten token TTL ahead of time or schedule a clear cutover window
## Recommendations
- assign an internal owner for every provider key
- record key rotation time
- put production key rotation in a trusted backend or ops workflow instead of relying on manual browser actions
### Logging and Usage
Source: https://downcity.ai/en/city-sdk-docs/operations/logging-and-usage/
Downcity does not decide where your logs live, but hooks give you one consistent integration point.
## Recommended fields
- user_id
- bureau_id
- model
- started_at
- ended_at
- status
- error
## Where to record
```ts
ai.hook.after(async (ctx) => {
await logService.write({
user_id: ctx.user?.user_id,
bureau_id: ctx.bureau?.bureau_id,
model: ctx.variant?.id,
model_meta: ctx.variant?.meta,
started_at: ctx.started_at,
ended_at: ctx.ended_at,
});
});
```
Once many products share one runtime, usage data becomes centralized by default, which makes cost analysis and business decisions much easier later.
### Troubleshooting
Source: https://downcity.ai/en/city-sdk-docs/operations/troubleshooting/
## client returns 401
Check:
- whether the request includes `Authorization: Bearer `
- whether the token has expired
- whether City has `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` configured
- whether the token was issued by the current Federation
- whether the Token's `bureau_id` belongs to the current product
## client returns 403
Check:
- whether the Bureau bound to the token has been paused
- whether the target Bureau was `active` when the admin side issued the token
## client returns 404
Check:
- whether the HTTP route is correct
- whether the target service has been registered in City
- whether the target Bureau existed when the admin side issued the token
## model not found or handler not hit
Check:
- whether the client-provided `model` has been registered through `AIChannel.model()` + `AIService.use()`
- whether the target model includes the required action for the current pathway, such as `text`, `stream`, or `image`
- whether the request included the intended model id
- whether `city.ai.catalog()` can see the model
These cases usually return `422`.
## City returns 500
Check:
- whether City has `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` and a provisioned administrator account
- whether the target service has a registered handler
- whether the handler or provider call threw an error without `statusCode`
- whether the `onError` hook recorded the original error
## provider call failed
Check:
- whether the matching key exists in City `.env`
- whether the handler reads the correct `input.env()` key
- whether model `meta` matches the current provider calling style
## File tools take a long time to finish
“生成文件内容中” means the model is still preparing file content; writing has not started. The received character count shows generation progress. “等待执行” means the input is ready, and “写入文件中” means writing has started.
If the model reaches its output limit or the response lacks a normal finish marker, the tool is not executed. Reduce the scope of each change, edit long documents by section, and save large SVG illustrations as separate files. If another interruption occurs, check the model service before repeating the same oversized request.
For `edit` input validation errors, correct the original call: `edits` must be an array, not a JSON-encoded string. Partial changes do not require rewriting the entire file.
## usage not recorded
Check:
- whether `AIService` and `CreditsService` are mounted before `UsageService` and supplied as its readers
- whether the AI provider returned final `RuntimeMetering`; executions without final metering only increase the execution count
- whether the Credits transaction is an applied positive charge; top-ups, failed, and pending transactions are excluded
- whether the query supplies real ordered `from` and `to` dates plus a valid IANA `timezone`
### Federation Client SDK
Source: https://downcity.ai/en/city-sdk-docs/reference/client-sdk/
The client API follows three explicit identities:
| API | Credential | Purpose |
| --- | --- | --- |
| `embassy.user` | User Token | Login, AI, payment, and user Services |
| `embassy.admin` | Admin Session Token | Federation administration |
| `Bureau` | Bureau Token | Trusted product backend and local User Token verification |
```ts
import { Bureau, Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url });
const bureau = new Bureau({ federation_url, bureau_token });
```
Do not place User Tokens, Admin Session Tokens, and Bureau Tokens in one shared client object.
Their owners, lifetimes, and trust boundaries are different.
### Runtime API
Source: https://downcity.ai/en/city-sdk-docs/reference/runtime-api/
This page is kept as a lightweight overview. The new reading order is:
- [Reference home](/en/city-sdk-docs/reference): choose an entry by role first
- [Federation](/en/city-sdk-docs/reference/federation)
- [HTTP API](/en/city-sdk-docs/reference/http-api)
- [Environment variables](/en/city-sdk-docs/reference/env-vars)
## What Federation is responsible for
- initialize the Runtime database
- validate `user_token`
- expose a unified `/v1/*` HTTP route space
- register Service, AIService, and services through `base.use(service)`
- mount Service actions and service routes into the same `/v1/*` space
- connect usage, plans, balances, billing, logs, and risk control through hooks
Continue with [Federation](/en/city-sdk-docs/reference/federation) for concrete methods.
### Federation
Source: https://downcity.ai/en/city-sdk-docs/reference/federation/
`Federation` is the server-side core instance (formerly called "City runtime" in older docs). It encapsulates HTTP routing, auth, env vars, and database infrastructure. The client-side entry is [`City`](/en/city-sdk-docs/reference/city-user); do not confuse the two.
## Minimal Usage
```ts
import { Federation } from "@downcity/federation";
const base = new Federation({ database });
```
## Service Registration
```ts
import {
AIService,
AIChannel,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
base_url: "https://api.deepseek.com",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const deepseek = new DeepSeekChannel();
const ai = new AIService();
ai.use(deepseek.model({ id: "deepseek-v4-flash", upstream_model: "deepseek-chat", name: "DeepSeek V4 Flash" }));
base.use(ai);
```
See [AIChannel & Model Registration](/en/city-sdk-docs/service/ai/provider).
## Official Service Registration
```ts
import { AccountsService, googleAccountsProvider } from "@downcity/services";
base.use(new AccountsService({
providers: [
googleAccountsProvider(),
],
}));
```
## Routing
```ts
import { serve } from "@hono/node-server";
serve({ fetch: (request) => base.fetch(request), port: 43127, hostname: "127.0.0.1" });
```
## HTTP Middleware
Use `base.middle()` to register Federation-level HTTP middleware. Middleware runs before the internal router and before Action body parsing, so callers can attach CORS, security headers, body size limits, coarse rate limits, request timeouts, or other HTTP policies at the edge of the Federation.
`@downcity/agent` only defines the middleware interface. It does not implement or export CORS, rate limit, timeout, security header, body limit, or client IP helpers. Those policies should come from your application, platform, or HTTP framework.
```ts
base.middle(async (ctx, next) => {
const started_at = Date.now();
const response = await next();
const headers = new Headers(response.headers);
headers.set("X-Downcity-Time", String(Date.now() - started_at));
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}));
```
Middleware enters in registration order and exits in reverse order. It can return a `Response` directly to short-circuit a request, or call `await next()` and then modify response headers.
## instruction aggregation
`Federation` can return a plain-text instruction document for the current instance:
```ts
const text = await base.instruction();
```
The text aggregates:
- how to use the Federation itself
- which services are currently mounted
- env requirements declared by each module
- extra usage notes contributed by services
To read the same document remotely from a trusted admin side, call:
```txt
GET /v1/base/instruction
```
This route is admin-only and returns `text/plain`.
## Auth
Federation automatically validates `user_token` and administrator session tokens. Access current user via `ctx.user`.
## Database
`ctx.db["table_name"]` provides CityTableApi. Supports SQLite and PostgreSQL.
### Embassy User
Source: https://downcity.ai/en/city-sdk-docs/reference/city-user/
Create an Embassy with only the Federation URL, then use its `user` identity domain.
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
const providers = await embassy.user.account.providers();
```
## Account
```ts
await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: { email: "user@example.com", password: "password" },
});
const current_user = await embassy.user.current();
const user_token = embassy.user.account.token();
await embassy.user.account.logout();
```
`bureau_id` belongs to the login request, not the Embassy constructor. A successful login updates
the User Token held by this instance. You may also provide an existing `user_token` when creating
the Embassy. Email login requires completed email verification. `login()` automatically exchanges the completed flow for a token; `status()` alone never delivers one. Logout revokes the current token before clearing local credentials and preserves it on failure for retry. New protected Bureau requests require online Federation validation; unavailable validation rejects access, while existing streams continue.
## Services
```ts
const models = await embassy.user.ai.catalog();
const methods = await embassy.user.payment.methods();
const services = await embassy.user.list_services();
const result = await embassy.user.service("rewrite")
.action("formal")
.invoke({ text: "hello" });
```
`embassy.user.get()` and `post()` target the trusted Bureau server resolved by Federation from the
current User Token. Embassy rejects forwarding that token to another origin.
### Embassy Admin
Source: https://downcity.ai/en/city-sdk-docs/reference/city-admin/
Administrator access is the `admin` identity domain of Embassy.
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
await embassy.admin.login({ admin_id: "owner", password: "password" });
```
The Federation returns a time-limited Admin Session Token. Embassy keeps it in the current
instance; `token()` reads it and `logout()` revokes it.
## Bureau management
```ts
const bureaus = await embassy.admin.bureaus.list();
const bureau = await embassy.admin.bureaus.create({
bureau_id: "product-web",
name: "Product Web",
server_url: "https://product.example.com",
});
await embassy.admin.bureaus.route.update({
bureau_id: bureau.bureau_id,
server_url: "https://new-product.example.com",
});
```
## Bureau Tokens
```ts
const issued = await embassy.admin.bureaus.tokens.issue({
bureau_id: "product-web",
purpose: "production backend",
});
const tokens = await embassy.admin.bureaus.tokens.list("product-web");
await embassy.admin.bureaus.tokens.revoke(issued.token_id);
```
Federation returns the plaintext Bureau Token only when it is issued. Store it in the trusted
backend and use the independent `Bureau` client for `identify()`; Embassy Admin does not identify
end-user requests.
### HTTP API
Source: https://downcity.ai/en/city-sdk-docs/reference/http-api/
All actions are exposed through the same shape:
```txt
POST /v1/{service}/{action}
GET /v1/{service}/{action}
```
The bearer credential decides the request identity:
- no token: `guest`
- `user_token`: `user`
- administrator session token: `admin`
Each action declares which identities it allows. The default is `["user"]`; `[]` means guest access is allowed.
## City Routes (common user-side routes)
| Route | Method | Purpose | Pathway |
| --- | --- | --- | --- |
| `/v1/ai/models` | `GET` | Model catalog | — |
| `/v1/ai/stream` | `POST` | Downcity Model Protocol stream transport | CityModel |
| `/v1/ai/image/create` | `POST` | Create image generation job | SDK |
| `/v1/ai/image/result` | `POST` | Poll image generation job | SDK |
| `/v1/ai/video` | `POST` | Video generation | SDK |
| `/v1/ai/tts` | `POST` | Text-to-speech | SDK |
| `/v1/ai/asr` | `POST` | Speech recognition | SDK |
| `/v1/ai/decision` | `POST` | Structured decision (System One) | SDK |
| `/v1/ai/chat/completions` | `POST` | OpenAI-compatible endpoint | OpenAI |
| `/v1/accounts/login/start` | `POST` | Specify `client_type`; receive `login_id`, `login_secret`, and the next step | — |
| `/v1/accounts/login/continue` | `POST` | Submit input with `login_id` and `login_secret` | — |
| `/v1/accounts/login/result` | `POST` | Query status with the claim proof; never returns a token | — |
| `/v1/accounts/login/exchange` | `POST` | Exchange the claim proof and stable `exchange_id` for `user_token` | — |
| `/v1/accounts/logout` | `POST` | Revoke the current Bearer User Token | — |
| `/v1/federation/token` | `GET` | Validate the current Bearer User Token online | — |
| `/v1/accounts/me` | `GET` | Read the current user and its verified Bureau | — |
| `/v1/accounts/oauth/callback` | `GET` | Third-party OAuth callback entry | — |
| `/v1/services` | `GET` | Registered service list | — |
| `/v1/{service}/{action}` | `POST` | Generic action call | — |
`/v1/accounts/oauth/callback` is the fixed return URL for GitHub, Google, and other third-party OAuth providers. Product clients usually do not call it directly.
Keep `login_secret` exclusively in the initiating client and send it in JSON request bodies, never URLs, pages, or logs. `client_type: "web"` requires same-origin requests and the initiating browser cookie. OAuth flows for `cli`, `desktop`, and `device` return a `user_code` that users read in the original client and enter on the authorization confirmation page. Exchange only after completion, reusing the same `exchange_id` on transport retries.
Bureau checks new protected requests online with Federation and rejects access when validation is unavailable. Logout revokes the current token; streams already running are not forcibly interrupted.
## Admin Routes (same route space, admin credential required)
| Route | Method | Purpose |
| --- | --- | --- |
| `/v1/bureaus/list` | `GET` | List Bureaus |
| `/v1/bureaus/create` | `POST` | Create a Bureau with required `server_url` |
| `/v1/bureaus/pause` | `POST` | Pause a Bureau |
| `/v1/bureaus/activate` | `POST` | Activate a Bureau |
| `/v1/bureaus/archive` | `POST` | Archive a Bureau |
| `/v1/bureaus/server/update` | `POST` | Update the Bureau's single Server entrypoint |
| `/v1/bureaus/current` | `GET` | Resolve the current Bureau from a User Token |
| `/v1/accounts/tokens/issue` | `POST` | Issue user_token |
| `/v1/env/list` | `GET` | View runtime env |
| `/v1/env/upsert` | `POST` | Write env variable |
| `/v1/env/remove` | `POST` | Delete env variable |
| `/v1/env/import` | `POST` | Bulk import .env |
## Auth Header
```http
Authorization: Bearer
Content-Type: application/json
```
## `GET /v1/ai/models`
The same route returns different visibility by identity:
- with `user_token`: only currently callable models
- with an administrator session token: the full code-registered model list plus `env_requirements`
```json
{ "items": [
{ "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash",
"description": "...", "modalities": ["text","stream"],
"tags": ["deepseek"],
"price": ["Input: 1 credit / 1K tokens", "Output: 3 credits / 1K tokens"],
"meta": {},
"reasoning": {
"efforts": [
{ "id": "low", "name": "Low" },
{ "id": "high", "name": "High" }
],
"default_effort": "low"
} }
]}
```
```json
{ "items": [
{ "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash",
"description": "", "modalities": ["text","stream","openai"],
"tags": ["deepseek"],
"price": ["Input: 1 credit / 1K tokens", "Output: 3 credits / 1K tokens"],
"meta": {},
"env_requirements": [
{ "key": "DEEPSEEK_API_KEY", "description": "deepseek API Key", "required": true }
] }
]}
```
Federation resolves both the user and Bureau identity from the bearer `user_token`. The request body
does not participate in identity resolution.
## `GET /v1/services`
```json
{
"items": [
{ "id": "ai", "name": "AI", "env": [] },
{ "id": "accounts", "name": "Accounts", "env": [] }
]
}
```
`env` exposes the runtime env requirements declared by the service so admin or debugging surfaces can render dependencies.
## `POST /v1/ai/stream`
This versioned Downcity Model Protocol transport accepts
`{ protocol_version: 1, model_id, call }`, where `call` is a `ModelCall` containing
messages, tools, reasoning, and generation options. SSE returns
`{ protocol_version: 1, event: ModelStreamEvent }`. Product code should use the
`CityModel` returned by the catalog or call `city.ai.stream({ model, call })`.
Both paths consume the same standard event stream.
When supplied, `call.reasoning.effort` must match the final model catalog's
`reasoning.efforts`. AIService validates it again after fallback selects the
execution model.
## `POST /v1/ai/decision`
Structured decision transport. Accepts
`{ model, state, questions }` where `state` is `{ type: "text", text }` or
`{ type: "json", data }`, and returns `{ answers, usage? }`. Each answer carries
`value`, an optional `probability`, and a `distribution` for choice questions.
Requests are validated before execution: question ids must be unique, choice
questions require 1 to 255 unique options, and `score` requires `min < max`.
Invalid input fails with 422 without recording usage.
Product code should call `city.ai.decision({ model, state, questions })`.
## `POST /v1/ai/chat/completions`
OpenAI-compatible endpoint. Accepts standard `{ model, messages, stream }` format.
Non-streaming returns OpenAI `chat.completion` JSON. Streaming returns OpenAI SSE.
Fully compatible with OpenAI SDK:
```ts
const openai = new OpenAI({
baseURL: "http://127.0.0.1:43127/v1/ai",
apiKey: "ub_xxx",
});
```
### Environment Variables
Source: https://downcity.ai/en/city-sdk-docs/reference/env-vars/
| Variable | Purpose |
| --- | --- |
| `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` | Lets Federation issue and validate `user_token` internally |
| `DOWNCITY_CITY_DATABASE_URL` | Optional. Specifies the database URL used by Federation |
| `OPENAI_API_KEY` | Example provider key; recommended to write it into the Federation database through Admin env |
| `OPENAI_BASE_URL` | Example provider base URL; recommended to write it into the Federation database through Admin env |
`fed deploy` interactively provisions the administrator ID and password. The password is stored as a PBKDF2 digest and administrator sessions are stored server-side; there is no administrator key environment variable. `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` and `BETTER_AUTH_SECRET` are generated automatically on first boot and stored in Federation's `env` table.
If `DOWNCITY_CITY_DATABASE_URL` is omitted, the default database path is:
```plaintext
.base/downcity.sqlite
```
## How provider env is used
The model handler reads directly from `input.env`:
```ts
import {
AIChannel,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const deepseek = new DeepSeekChannel();
```
So the practical rules are simple:
- you choose the env key names yourself
- uppercase snake case is recommended
- read values through `input.env(key)` inside `stream()`
## Write values into the database
A trusted backend can write provider keys through Embassy Admin:
```ts
await embassy.admin.env.upsert({
key: "DEEPSEEK_API_KEY",
value: "sk-xxx",
});
```
These values are stored in the `env` table inside the Federation database. Business runtime reads only from that Federation-managed env table and no longer falls back to `.env` or process environment variables.
See also [AIChannel environment variables](/en/city-sdk-docs/guides/provider-env).
### City SDK
Source: https://downcity.ai/zh/city-sdk-docs/
Downcity 的 City SDK 用来创建、部署和调用 City。一个 Federation 可以同时服务网页、扩展、App、桌面工具、客户 Demo 或内部工具,不需要每个 AI 产品都重搭一套 AI 后端。
City 对外暴露的不是 provider API,而是稳定的**产品调用边界**。在 City 内部,能力通过 `Service`、`Action` 和 `AIService` 组织:
- 产品侧拿到 `user_token`(其中包含 `bureau_id`)
- 用 `Embassy User` 调 `text()`、`stream()`、`image()` 这类 service
- City 内部自己决定模型目录、provider 路由、hooks、usage 和 env
服务端运行时的类叫 `Federation`(本文里也称 Federation),产品侧用 `City` 客户端去访问它。两者的边界见 [Federation 与 City](/zh/city-sdk-docs/concepts/federation)。
## 先选你的阅读入口
## 一张图看懂它怎么工作
多个产品 client
网页、扩展、App、桌面工具、客户 Demo、内部工具。
Embassy User + user_token (which contains bureau_id)
→
Federation
校验 token,读取模型目录,再把请求分发到 AIService、服务或自定义 Service 的 action。
hooks 与 usage
把套餐、余额、扣费、日志、风控接进 Federation,但业务规则仍然留在你自己的系统里。
→
上游调用层
OpenAI、Claude、Gemini、私有模型,或你自己的 AI Gateway / 中转服务。
对产品来说,City 暴露的是稳定的 Service action 和 model;上游接什么 provider、网关或私有部署,都是服务端内部细节。
## City SDK 负责什么
- Runtime 数据库和 Service 声明的表
- provider key 和 Runtime env
- `user_token` 校验与管理员会话管理能力
- `Provider` + `AIService.use()` 注册模型,自动提供 SDK 和 OpenAI 兼容两条通路
- `city.ai.catalog()` 读取 Runtime 注册出来的公开模型目录
- usage 记录和 hooks 扩展点
## 它不负责什么
- 你的用户系统
- 订单、订阅、CRM、内容数据
- 产品自己的页面、交互和业务体验
- 对外暴露“统一 provider API”给其他系统直接调用
更准确地说:
> City SDK 负责 Federation 以及其中面向 Service 的 AI 基础设施。
> 产品继续负责自己的用户、业务、界面和商业模式。
## 推荐阅读路线
1. 第一次接入,先去 [快速开始](/zh/city-sdk-docs/quickstart)
2. 想彻底理解这个产品模型,再看 [核心概念](/zh/city-sdk-docs/concepts)
3. 准备接真实业务,再看 [场景指南](/zh/city-sdk-docs/guides)
4. 想按 npm package 理清职责时,看 [Packages](/zh/city-sdk-docs/packages)
5. 需要具体接口时,再回到 [参考](/zh/city-sdk-docs/reference)
### Federation、Bureau、Embassy 与 City
Source: https://downcity.ai/zh/city-sdk-docs/concepts/federation/
四个概念拥有独立职责:
- `Federation` 是权威后端和信任根,拥有账户、Bureau 记录、Service、模型目录、Usage、
Env 和 Token 签发。
- `Bureau` 是 Federation 内的产品或业务分区。Bureau Token 表示可信产品后端身份。
- `Embassy` 是 user 与 admin 访问 Federation 的窗口,只有 `user` 和 `admin` 两个身份子域。
- `City` 是 `@downcity/city` 提供的应用组合根,持有 Workspace,也可以持有 Embassy 服务客户端;它不是 Federation
客户端,也不是 Federation 中需要注册的身份。
```ts
import { Embassy, Federation } from "@downcity/federation";
import { City } from "@downcity/city";
const federation = new Federation({ database });
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
const city = new City({ embassy, workspaces: [workspace] });
const agent = new Agent({ id: "assistant" });
city.agents.add(agent);
```
## 身份边界
User Token、Admin Session Token 和 Bureau Token 不能混用:
- `embassy.user` 持有或获取 User Token。
- `embassy.admin` 持有或获取 Admin Session Token。
- `new Bureau({ bureau_token })` 持有长期 Bureau 机器凭证。
- City 不会成为上述任何一种身份。City 中运行的 Agent 可以把 Embassy 或其他客户端当作
普通依赖使用。
## CLI 对应关系
- `fed` / `downfed` 部署和管理 Federation。
- `city` / `downcity` 管理本地 Agent 宿主。
两个命令可以来自同一个发行包,但它们拥有不同的运行时和生命周期状态。
### 产品模型
Source: https://downcity.ai/zh/city-sdk-docs/concepts/product-model/
Downcity 最核心的心智不是“一个产品对应一个后端”,而是:
> 一个 Federation,服务多个 AI 产品和工作流。
这样做的原因很简单。真正被重复建设的,通常不是你的业务后端,而是 AI 调用这一层:
- provider key 存放
- 模型目录
- token 校验
- usage 记录
- hooks、限额、扣费和日志
这些能力一旦你开始做第二个、第三个 AI 产品,就会反复出现。
## 多个产品,一套 Federation
产品 A
网页工具、内容产品、工作流小工具。
bureau_id = product_a
产品 B
Chrome 扩展、桌面工具、移动端入口。
bureau_id = product_b
产品 C
客户 Demo、内部工具、行业专题站。
bureau_id = product_c
→
Federation
City 模型目录、provider env、service 路由、token 校验、usage 和 hooks。
## 什么应该留在 Federation
- 模型目录和 provider 配置
- `user_token` 校验
- `city.ai.stream()`、`city.ai.image_create()` / `city.ai.image_result()` 这类 AI service 调用能力
- `city.ai.decision()` 这类结构化决策调用
- usage 记录、日志、限额和商业 hooks
- 多个产品可以共用的调用逻辑
## 什么继续留在产品或业务系统
- 用户注册、登录和账号体系
- 订单、订阅、余额和 CRM
- 页面、交互、工作流和内容数据
- 每个产品自己的品牌、定价和市场策略
换句话说,Downcity 集中的是“AI 基础设施”,不是“全部业务系统”。
## 为什么这比“每个项目一个后端”更稳
如果你每做一个新产品都复制一套 AI 后端,很快会遇到这些问题:
- provider key 分散在多个地方
- token 规则不一致
- usage 和日志口径不统一
- 新 provider、新模型、新限额策略要改很多 repo
把 AI 调用层收敛到 Federation 后,新产品通常只需要做两件事:
1. 通过 `Embassy Admin` 创建一个 Bureau
2. 在产品侧接入 `Embassy User`
## `bureau_id` 在这个模型里代表什么
`bureau_id` 不是模型名,也不是租户系统的全部抽象。
它是 runtime 看待“产品边界”的稳定主键,用来回答这些问题:
- 这是哪个产品发起的调用
- 这个产品是否处于 `active` 状态
- hooks 和 usage 应该按哪个产品维度记录
- 一个用户在不同产品里的调用是否需要区分
如果你在做多产品组合,`bureau_id` 是最稳定的边界。
## model 在产品层里的角色
当前实现里,model 是 Runtime 注册出来的能力单元,不属于某个 city。
对产品来说,model 更像“可见能力”:
- 可以显式传给 `city.ai.stream({ model, call })`
- 每次 AIService 调用都必须显式传入模型 ID 或 `CityModel`
这意味着产品端看到的是稳定模型 ID,而不是上游 provider 的具体细节。
## 模型类别与能力
目录中的每个模型都带一个 `model_type`,表示它属于哪一类:`language`、`decision`、`image`、`sound` 或 `video`。它由 Federation 从模型实际实现的能力推导,与 `modalities` 同源。
两者分工不同:`model_type` 回答“它是什么”,用于展示与分组;`modalities` 回答“它能做什么”,判断可执行能力应读 `modalities`。例如对话模型带 `text` / `stream`,决策模型只带 `decision`,因此只有前者能交给 Agent。
### AI 架构
Source: https://downcity.ai/zh/city-sdk-docs/concepts/architecture/
```text
City 客户端 / Agent
|
v
CityModel(ModelClient)
|
v
Federation AIService
鉴权 -> 模型查找 -> fallback -> reasoning -> 计量
|
v
AIChannel.stream(AIChannelStreamInput)
|
v
OpenAI-compatible 或私有 Provider Adapter
```
`CityModel` 是客户端可执行模型,持有公开的 `CityModelDescriptor` 和 Federation transport,不知道上游密钥或厂商协议。
`AIService` 是服务端编排器,负责路由、fallback、reasoning 校验、usage 归一化和计费。
`AIChannel` 是服务端上游边界,负责连接配置、真实模型选择、服务端 `provider_options`,并通过 Provider Adapter 返回 `ModelStreamEvent`。
OpenAI 兼容端点只是适配器,不是第二条执行路径:
```text
OpenAI JSON/SSE -> ModelCall -> AIService -> AIChannel.stream -> ModelStreamEvent -> OpenAI JSON/SSE
```
### token 模型
Source: https://downcity.ai/zh/city-sdk-docs/concepts/token-model/
Downcity 使用管理员会话 Token 管理 Federation,并使用 `user_token` 让终端 client 调用 service。
可信后端
你的后端使用管理员 ID 和密码登录,然后持有有时限的管理员会话 Token。
创建 city / 申请 user_token
→
Downcity
校验管理员会话 Token,签发 `user_token`,并在内部保存 provider key。
→
终端 client
前端、扩展或 App 使用 Embassy User 携带 `user_token` 调用 Service。
拿不到管理员凭证
## user_token
`user_token` 是给终端用户或产品 client 使用的调用凭证。Embassy Admin 可以基于 `bureau_id + user_id` 向 Federation 申请。
- `bureau_id`
- `user_id`
- `metadata`
- `ttl`
登录开始时由产品配置明确 `bureau_id`;登录完成后,client 调用 service 只携带 `user_token`,
`bureau_id` 由 Federation 从已验证 Token 中读取。client 不接触 provider key,也不接触管理���会话 Token。
User Token 使用单一 `downcity:user` audience。Federation 与 Bureau Server 校验同一个 audience;
Bureau Server 还会比较 Token 中的 `bureau_id` 与自身绑定的 Bureau ID。`bureau_id` 始终保持
产品提供的原始值,不参与 audience 拼接或规范化。
## 管理员会话 Token
使用 `fed deploy` 配置的管理员 ID 和密码登录 Embassy Admin。登录响应会返回有时限的管理员会话 Token,用于创建 Bureau、管理 env,以及请求 Federation 为用户签发 `user_token`。
```ts
const embassy = new Embassy({
federation_url: "https://base.example.com",
admin_token: administrator_session_token,
});
const bureau = await embassy.admin.bureaus.create({
name: "Chrome Extension",
server_url: "https://bureau.example.com",
});
```
## 多产品复用
多个 client 可以归属同一个 Bureau。签发 token 时明确写入 `bureau_id`:
```ts
const user = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: bureau.bureau_id,
user_id: "user_123",
metadata: {
plan: "pro",
},
ttl: "7d",
});
console.log(user.user_token);
```
### 信任边界
Source: https://downcity.ai/zh/city-sdk-docs/concepts/trust-boundary/
Downcity 的安全模型建立在清晰的信任边界上。
## 可以放在 client
- `Embassy User`
- `federation_url`
- `user_token`
- 模型 ID
- prompt、messages、size、voice 等用户输入
## 必须留在可信环境
- provider API key
- 管理员 ID、密码和会话 Token
- Runtime env 管理权限
- token 签发逻辑
- 套餐、余额、扣费和风控判断
## City 的边界
City 负责校验 `user_token`,读取 Runtime env,并通过 service handler 调用真实模型。client 不需要知道 provider key,也不需要知道你内部如何扣费。
```plaintext
client 可以知道:我要调用哪个模型
client 不应该知道:这个模型背后用哪个 provider key
```
这个边界让纯客户端产品可以直接接入 City,同时不暴露敏感配置。
### usage 与商业模式
Source: https://downcity.ai/zh/city-sdk-docs/concepts/usage-and-billing/
Downcity 不替你决定商业模式。它提供 hook,让你把自己的套餐、余额、限额、日志和扣费系统接进 AI 调用生命周期。
## before hook
`before` 适合做调用前判断:
- 用户是否有权限
- 套餐是否允许该模型
- 今日额度是否足够
- 是否触发风控或频控
## after hook
`after` 适合做调用后记录:
- 记录 provider usage
- 写调用日志
- 扣除余额
- 同步账单
- 按产品维度分析成本
```ts
const ai = new AIService();
ai.hook
.before(async (ctx) => {
await quotaService.check(ctx.user?.user_id, ctx.variant?.id);
})
.after(async (ctx) => {
await usageService.record({
user_id: ctx.user?.user_id,
bureau_id: ctx.bureau?.bureau_id,
model: ctx.variant?.id,
output: ctx.output,
});
});
base.use(ai);
```
这样多个 AI 产品可以共享同一套商业逻辑,而不是每个项目各写一遍。
### 现在怎么用
Source: https://downcity.ai/zh/city-sdk-docs/quickstart/use-now/
如果你想先按仓库里的现成产品体验,入口是:
- `templates/localfed`
- `cli/city`
跑通后,你会得到这个调用链:
```plaintext
City Client
↓ Embassy Admin 创建 Bureau / 请求 Federation 签发 user_token
Federation
↓ Embassy User 携带 user_token 调用 service
AIChannel → AIService → handler
```
## 1. 启动 City
```ts
import {
Federation,
AIService,
AIChannel,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./data.sqlite" });
const base = new Federation({ database });
// 注册一个 echo 模型
class EchoChannel extends AIChannel {
constructor() {
super({ id: "echo" });
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const input_text = input.call.messages
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "model_start", request_id: crypto.randomUUID(), model_id: input.model.id });
controller.enqueue({ type: "text_start", content_id: "echo_text" });
controller.enqueue({ type: "text_delta", content_id: "echo_text", delta: `Echo: ${input_text}` });
controller.enqueue({ type: "text_finish", content_id: "echo_text" });
controller.enqueue({
type: "model_usage",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
});
controller.enqueue({ type: "model_finish", finish_reason: "stop" });
controller.close();
},
}),
};
}
}
const echo = new EchoChannel();
const ai = new AIService();
ai.use(echo.model({ id: "local-echo", upstream_model: "local-echo", name: "Local Echo" }));
base.use(ai);
import { serve } from "@hono/node-server";
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 43127, hostname: "127.0.0.1" });
```
## 2. 创建 Bureau 并请求签发 token
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "http://127.0.0.1:43127",
admin_token: administrator_session_token,
});
const token = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: "user_123",
metadata: { plan: "pro" },
ttl: "7d",
});
```
## 3. 产品侧调用
```ts
const user_embassy = new Embassy({
federation_url: "http://127.0.0.1:43127",
user_token: token.user_token,
});
const catalog = await user_embassy.user.ai.catalog();
const model = catalog.require("local-echo");
// SDK 通路
const stream = await model.stream({
messages: [{
role: "user",
content: [{ type: "text", text: "你好 Downcity" }],
}],
});
for await (const event of stream) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
```
## 4. 你真正复用的是什么
多个产品接入后,复用的是这些东西:
- `.env` 中的 provider key
- `user_token` 校验
- AIService 的模型路由
- hook 里的套餐、限额、日志和扣费
下一步:
- [AIChannel 与模型注册](/zh/city-sdk-docs/service/ai/provider)
- [调用 AI 服务](/zh/city-sdk-docs/service/ai/calling)
- [创建 City](/zh/city-sdk-docs/quickstart/create-city)
- [签发 user_token](/zh/city-sdk-docs/quickstart/issue-token)
### 创建 Federation
Source: https://downcity.ai/zh/city-sdk-docs/quickstart/create-city/
服务端运行时的类是 `Federation`(文档里也叫 Federation),产品侧用 `City` 客户端去访问它。两者的区别见 [Federation 与 City](/zh/city-sdk-docs/concepts/federation)。
最快的本地创建和启动方式是:
```bash
fed create ./my-fed
cd my-fed
pnpm install
fed deploy
```
默认模板使用 Node.js + SQLite。`fed deploy` 从 `12314` 开始分配端口,把进程登记到系统级 Fed 管理状态,并交互式配置管理员 ID 和密码;之后可以在任意路径运行 `fed` 查看并直接进入管理。下文是需要手动嵌入 Federation runtime 时的 SDK 写法。
安装包:
```bash
pnpm add @downcity/federation @downcity/database-sqlite
```
准备好数据库即可。`fed deploy` 会交互式配置管理员 ID 和密码;密码只保存为 PBKDF2 摘要,登录后使用管理员会话 Token。Federation 首次启动时会自动补齐 `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 和 `BETTER_AUTH_SECRET`。provider key 则在启动后通过 Admin API 或 `fed` 的 admin workspace 写入:
```bash
OPENAI_API_KEY="..."
OPENAI_BASE_URL="https://api.openai.com/v1"
```
创建 Federation:
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./data.sqlite" });
const base = new Federation({ database });
```
默认数据库会落到:
```plaintext
./data.sqlite
```
本地 SQLite 使用 `@downcity/database-sqlite`。正式部署使用 PostgreSQL 时,改用 `@downcity/database-postgresql`。
如果要使用 Cloudflare Workers / D1,传入 D1 Adapter:
```ts
import { Database } from "@downcity/database-d1";
const database = new Database({ binding: env.DB });
const base = new Federation({ database });
```
Federation 会在第一次 `health()`、`fetch()`、`table()` 等运行时操作时自动初始化。
如果你的 Federation 还需要自己的业务表,可以通过 Service 或服务声明表。表会挂在对应 Service 的命名空间下,并和 Federation 默认数据库使用同一个数据库连接:
```ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { Service } from "@downcity/federation";
const notes = sqliteTable("notes", {
id: text("id").primaryKey(),
title: text("title").notNull(),
status: text("status").notNull(),
});
const notesService = new Service({
id: "notes",
tables: { notes },
});
base.use(notesService);
const table = await base.table("notes.notes");
await table.insert({
id: "note_1",
title: "First note",
status: "draft",
});
```
常规项目不需要手动建表;第一次运行时操作会自动准备 Federation 内置表和 Service 声明的表。
启动服务:
```ts
import { serve } from "@hono/node-server";
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 3001, hostname: "127.0.0.1" });
```
这个服务就是后续多个产品共用的 Federation。管理和业务数据暂时通过你自己的数据库与产品后端处理。
### 添加模型
Source: https://downcity.ai/zh/city-sdk-docs/quickstart/add-model/
创建 `AIChannel`,实现 Downcity Model Protocol stream,再注册 `AIModelSpec`:
```ts
import {
AIChannel,
AIService,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const channel = new DeepSeekChannel();
const ai = new AIService();
ai.use(channel.model({
id: "deepseek-v4-flash",
upstream_model: "deepseek-chat",
name: "DeepSeek V4 Flash",
context_window: 128_000,
}));
federation.use(ai);
```
这一个 `stream()` 同时支持 `city.ai.stream()`、`CityModel` 和 `/v1/ai/chat/completions`。
reasoning、fallback、计费和 `provider_options` 详见 [AIChannel 与模型注册](/zh/city-sdk-docs/service/ai/provider)。
## 结构化价格
模型可以通过 `pricing` 提供机器可读的价格方案。`unit` 表示计费对象,`scale` 表示每个价格对应的单位数量;多个方案可通过 `dimensions` 表达时段或档位。
```ts
pricing: {
currency: "USD",
unit: "token",
scale: 1_000_000,
rates: { input: 1.6, output: 9.6, cached_input: 0.16 },
}
```
旧版 `price` 字段仅作为兼容投影,新增模型不应单独维护价格文案。
### 签发 User Token
Source: https://downcity.ai/zh/city-sdk-docs/quickstart/issue-token/
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
admin_token: administrator_session_token,
});
const issued = await embassy.admin.service("accounts")
.action("tokens/issue")
.invoke({
bureau_id: "product-web",
user_id: "user_123",
metadata: { plan: "pro" },
ttl: "7d",
});
```
Token 签发由 Federation 执行。Bureau 负责验证 User Token,但不签发它。
### 连接 Embassy
Source: https://downcity.ai/zh/city-sdk-docs/quickstart/connect-client/
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://fed.example.com",
user_token: "ub_xxx",
});
const catalog = await embassy.user.ai.catalog();
const model = catalog.get("gpt-5.4");
if (!model) throw new Error("model not found");
const result = await embassy.user.ai.text({
model,
prompt: "写一段欢迎语",
});
```
自定义或官方 Service 使用 `embassy.user.service(name)`。Provider Key、模型路由和 Usage
记账都留在 Federation 内部。
### 纯客户端产品
Source: https://downcity.ai/zh/city-sdk-docs/guides/pure-client-products/
纯客户端产品不是没有后端,而是不为每个产品重复创建 AI 后端。
Downcity 让新项目可以先只写前端,直接接入已经部署好的 Federation。这样你可以先验证产品体验,再决定要不要补自己的业务后端。
## 适合的产品形态
- 小工具网页
- Chrome 扩展
- 桌面壳
- 移动端入口
- 客户 Demo
- 内部工具
## 推荐边界
client 负责:
- UI 和交互
- 调用 Embassy User
- 登录开始时使用产品配置中的 `bureau_id`
- 保存或刷新 `user_token`
Federation 负责:
- provider key
- 模型目录
- `user_token` 校验
- service 路由
- usage 记录
- hook 和商业逻辑
## 一个最常见的最小结构
```plaintext
产品前端
↓
你的登录/签 token 接口
↓
Federation
```
这里的“产品后端”可以一开始非常薄,只做:
- 用户登录
- 通过 Embassy Admin 请求 User Token
- 把 `user_token` 返回给前端
普通 service 请求不再重复提交 `bureau_id`;Federation 从已验证的 `user_token` 中读取。
## 为什么这样更适合早期产品
- 不需要为每个 AI 想法复制一套 provider 集成
- 前端可以更快上线验证
- 多个产品共用同一套 AI 调用、日志和限额逻辑
如果后续产品变复杂,再补业务后端;不要在第一天就为每个 AI 想法复制一套 AI 基础设施。
### 多产品共用一套 Federation
Source: https://downcity.ai/zh/city-sdk-docs/guides/multi-product-city/
多个产品共用一套 runtime 时,最重要的不是“省一台服务器”,而是把**共享能力**和**产品差异**拆开。
应该共享的是:
- provider env
- 模型目录
- service 路由
- hooks、usage 和日志
- `user_token` 校验能力
应该继续留在产品侧的是:
- 页面和交互
- 用户体系
- 套餐、订单、余额的业务规则
- 每个产品自己的品牌、定价和定位
## 一张图看清楚复用边界
产品 A
例如网页工具。
bureau_id = web
产品 B
例如 Chrome 扩展。
bureau_id = extension
产品 C
例如客户 Demo 或内部工具。
bureau_id = demo
→
Federation
复用模型目录、provider env、service 逻辑、usage 记录和 hooks。
## `bureau_id` 应该怎么用
`bureau_id` 是产品边界,而不是模型边界。
它适合用来表达:
- 这是哪个产品发起的调用
- 某个产品是否还处于 `active`
- hooks 应该把 usage 记到哪个产品
- 某个用户在不同产品里的调用是否要区分
一个常见做法是给每个产品一个稳定的 `bureau_id`:
```ts
const issued = await admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: "user_123",
metadata: {
plan: "starter",
channel: "extension",
},
});
```
## token metadata 适合放什么
`bureau_id` 负责“这是哪个产品”,而 token metadata 更适合表达“这个用户在这个产品里的业务状态”。
常见字段:
- `plan`
- `organization_id`
- `channel`
- `feature_flag`
- `team_id`
这样 hooks 就可以基于 `ctx.bureau` 和 `ctx.user.metadata` 做差异化处理,而不需要为每个产品单独复制一套 City。
## 多产品共享时的推荐分层
### 产品侧
- 保存自己的路由、界面和业务体验
- 用 `Embassy User` 调 City
- 只持有 `user_token`(其中包含 `bureau_id`)
### 业务后端
- 负责登录、订单、余额、订阅和业务数据
- 在可信环境里使用 `Embassy Admin`
- 在登录成功后申请 `user_token`
### City
- 负责模型目录
- 负责 provider 调用
- 负责 usage 和 hooks
- 负责可复用的 AI service 逻辑
## 什么时候应该新建一个 city
这些场景通常值得新建 city:
- 一个独立的产品或独立市场
- 需要单独暂停或启用调用
- 需要单独做 usage 统计或商业策略
- 需要区分浏览器扩展、Web App、客户 Demo 这类不同产品形态
这些场景通常不需要新建 city:
- 同一产品里的不同页面
- 同一产品里的不同 feature flag
- 单个用户的不同会话
这些更适合继续放在你自己的业务系统或 token metadata 里。
### Cloudflare Workers
Source: https://downcity.ai/zh/city-sdk-docs/guides/cloudflare-workers/
如果你准备把 Downcity 部署到 Cloudflare Workers,推荐做法不是等一个单独的 edge package,而是直接把 `@downcity/agent` 接到 Workers runtime。
公开仓库里保留了 `templates/edgefed` 作为开发者快捷示例。Downcity 官方私有 Worker 实现维护在公开仓库之外。
## 这条路径解决什么
Cloudflare Workers 和 Node.js 最大的不同,不是 HTTP 入口,而是 runtime 资源模型不同:
- 数据库通常来自 `env.DB` 这类 binding
- 环境变量读取不再是本地 `.env` 文件
- request origin 可能需要在每次请求时同步到 service
- Worker isolate 会复用,所以 runtime cache 需要自己管理
这也是为什么这里更适合用 guide 解释接法,而不是再抽一个空的 npm 包。
## 最小接法
Federation 需要一个 D1 Database Adapter:
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-d1";
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env) {
const database = new Database({ binding: env.DB });
const base = new Federation({ database });
await base.health();
return base.fetch(request);
},
};
```
这里的重点是 Adapter 明确拥有 D1 的运行时差异:
- D1 binding 只传给 `@downcity/database-d1`
- Adapter 内部组合 Drizzle,并负责快照冲突重试与原子提交
- City 和 Service 都不会拿到原始 D1 binding
- 当前请求 origin 可以在请求前同步给需要 OAuth callback 的 service
## 从 `templates/edgefed` 快捷示例开始
仓库里的 `templates/edgefed/src/index.ts` 只保留 Worker 接入 Federation 必需的边界:
- 用 `@downcity/database-d1` 把 `env.DB` 接成 Federation 可用数据库
- 在 Worker isolate 内复用 Federation 实例
- 把 HTTP request 交给 Federation 处理
业务 Service、模型和 provider 应由产品按需组合,不属于 Edge runtime 模板。
## 和 Node 路线的边界
Worker 和 Node 是同一套心智模型:都创建对应的 Database Adapter,然后传给 `new Federation({ database })`。
- Node.js 本地使用 `@downcity/database-sqlite`
- Node.js 生产可使用 `@downcity/database-postgresql`
- Workers / D1 使用 `@downcity/database-d1`
## 下一步
- 如果你还没搭 City 本体,先读 [Federation](/zh/city-sdk-docs/packages/federation/federation)
- 如果你要管理 provider key,继续读 [provider 环境变量](/zh/city-sdk-docs/guides/provider-env)
- 如果你需要最小 Worker 接入示例,直接看 `templates/edgefed/src/index.ts`
### 鉴权与 user_token
Source: https://downcity.ai/zh/city-sdk-docs/guides/auth-and-user-token/
Downcity 不替你托管用户系统。它只要求 client 请求携带可校验的 `user_token`。
## 已有登录系统
如果你已经有登录系统,可以在登录后用 Embassy Admin 请求 Federation 签发 Downcity Token:
```ts
const user = await embassy.admin.service("accounts").action("tokens/issue").invoke({
bureau_id: "demo",
user_id: session.user.id,
metadata: {
plan: session.user.plan,
},
ttl: "7d",
});
```
然后把两样东西交给产品侧:
- `bureau_id`
- `user_token`
## 没有复杂后端
如果新产品是纯客户端项目,可以把 token 签发收敛到 Federation,而不是每个产品写一套服务。
## token 不应该做什么
- 不要把 provider key 写进 token。
- 不要把不可公开的计费规则写进 token。
- 不要让 client 持有管理员 ID、密码或会话 Token。
token 只表达“这个用户是谁”和“这次请求有哪些业务 metadata”。
### AIChannel 环境变量
Source: https://downcity.ai/zh/city-sdk-docs/guides/provider-env/
在 Channel 上声明密钥:
```ts
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(input: AIChannelStreamInput) {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
```
`env` 会为模型目录的管理端视图生成必填 env 声明。一个 Channel 需要多个环境变量时直接添加多个字段。
值存储在 Federation env 表中,执行时通过 `input.env(key)` 读取。它们不会进入 `CityModelDescriptor` 或客户端运行时。
### 模型适配器
Source: https://downcity.ai/zh/city-sdk-docs/guides/model-adapters/
Downcity 核心只有一种模型协议。OpenAI Chat Completions 只在 HTTP 边界适配:
```text
City SDK / CityModel ------\
-> ModelCall -> AIService -> AIChannel.stream()
OpenAI Chat Completions ---/ -> ModelStreamEvent
```
OpenAI-compatible 上游使用 `stream_openai_compatible_model()`。私有 HTTP 协议应实现自己的 Adapter,把 `ModelCall` 转成厂商请求,并返回 `AIChannelStreamResult`;AIService 不理解厂商请求格式。
OpenAI Chat Completions 只是同一 Model Protocol 路径外层的 HTTP Adapter。没有 raw passthrough,也没有第二条模型执行链,因此 tools、fallback、reasoning、计量和计费在不同客户端之间保持一致。
Channel 负责:
- 密钥和 base URL;
- 上游模型工厂与协议选择;
- 模型级 `provider_options`;
- usage 解释和计费;
- 私有流到 `ModelStreamEvent` 的转换。
AIService 负责:
- 模型注册表和公开目录;
- 鉴权和可用性;
- 媒体 fallback 与 reasoning 校验;
- 计量、余额检查和扣费结算。
### hook 与商业逻辑
Source: https://downcity.ai/zh/city-sdk-docs/guides/hooks-and-billing/
Downcity 的 hook 分三层:Action → Service → Global。执行顺序从外层到内层:
```
global.before
→ service.before
→ action.before
→ action.run() ← 核心逻辑
→ action.after ← 扣费
→ service.after
→ global.after
```
## Action 级 hook
每个 Action 有独立的 hook,适合挂业务逻辑:
```ts
const zh2en = svc.action("zh2en", async (ctx) => {
return await translate(ctx.input.text, "en");
});
// 调用前检查余额
zh2en.before(async (ctx) => {
const bal = await balance.get(ctx.user!.user_id);
if (bal <= 0) throw new Error("余额不足");
});
// 调用后扣费(可访问 ctx.output)
zh2en.after(async (ctx) => {
await deductBalance(ctx.user!.user_id, 10);
await logUsage(ctx);
});
```
## Service 级 hook
同一 Service 下所有 Action 共享:
```ts
svc.hook.before(async (ctx) => {
console.log(`${ctx.user?.id} → ${ctx.service?.id}.${ctx.action?.id}`);
});
svc.hook.after(async (ctx) => {
// 聚合统计、统一日志
});
```
## 对比
| | Action hook | Service hook |
|---|---|---|
| 粒度 | 单个 action | 该 service 下所有 action |
| 用途 | 余额检查、扣费、per-action 日志 | 公共校验、聚合统计 |
| 注册 | `action.before(fn)` | `svc.hook.before(fn)` |
## ctx 中的关键字段
```ts
interface Context {
input: Record // 请求参数
output?: unknown // Action 执行结果(after 可读)
user?: { user_id, metadata } // 当前用户
bureau?: { bureau_id, status } // 已验证的 Bureau 身份
service?: { id, name } // 当前 Service
action?: { id } // 当前 Action
}
```
### 账户登录
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/client/auth-flows/
账户操作统一位于 `embassy.user.account`。
```ts
const providers = await embassy.user.account.providers();
const login = await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: {
email: "user@example.com",
password: "password",
},
});
```
邮箱须完成验证后才能登录。多步骤 Provider 使用 `login_start()` 和 `continue()`,同一账户访问器保管发起端领取凭据。`status()` 只查询状态;完成后调用 `exchange()` 领取并保存 User Token,同一访问器在网络失败重试时保留稳定的交换 ID。`login()` 自动完成交换。
OAuth 登录向 `login()` 传入 `on_authorize: async (url, user_code) => { ... }`。如有 `user_code`,先在原始客户端展示,再打开 `url`,让用户将短码输入浏览器授权确认页。此类流程使用 `client_type: "cli"`、`"desktop"` 或 `"device"`;直接浏览器 `"web"` 流程要求同源请求和浏览器绑定 Cookie。领取凭据不得进入 URL 或日志。
`token()` 读取当前 User Token。`await embassy.user.account.logout()` 先撤销当前 Token,再清理本地凭证;失败保留凭证以便重试。Bureau 对新的受保护请求向 Federation 在线验证,检查不可用时拒绝访问;已开始的流式请求不强制中断。
登录后继续使用同一个 `embassy.user` 调用 `current()`、AI、Payment 和 Service。
### User 与 Admin Service
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/client/service-calls/
User 与 Admin 使用同一套 Service 协议,但属于两个独立身份子域。
```ts
const user_services = await embassy.user.list_services();
const profile = await embassy.user.current();
const result = await embassy.user.service("rewrite")
.action("formal")
.invoke({ text: "hello" });
const admin_services = await embassy.admin.list_services();
const env = await embassy.admin.env.list();
```
Federation 模型目录与 AI 执行使用 `embassy.user.ai`,Payment 使用
`embassy.user.payment`。Admin-only 管理能力始终留在 `embassy.admin`,不要把管理员凭证
传入 user 子域。
### Federation 运行时
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/federation/
`Federation` 是 Downcity 的服务端长期运行容器(文档里也叫 Federation)。它把 Database Adapter、service、鉴权和统一 `/v1/*` 路由收在同一个服务端入口里。产品侧访问它用的是 `City` 客户端,区别见 [Federation 与 City](/zh/city-sdk-docs/concepts/federation)。
## 这个概念在说什么
可以先把 Federation 理解成“多个产品共用的 AI infrastructure runtime 进程”。
它长期持有这些能力:
- Database Adapter
- service 注册表
- 服务挂载点
- HTTP 路由入口
- token、city、env 等基础设施
所以你通常不是“调用一下 Federation”,而是“启动一套 Federation runtime,然后让多个产品 client 长期连接它”。
## 什么时候你需要关心这一页
- 你准备第一次真正把 Federation 跑起来
- 你想理解为什么 `Federation` 只需要一个 Database Adapter
- 你已经会写 service,但还不清楚 `fetch()`、`serve()` 各自用在什么地方
- 你要把 Federation 接到现有 Node 服务、Cloudflare Worker 或别的 HTTP 入口上
## 什么时候这页不是重点
- 你只想知道产品前端怎么用 `City` 客户端调用:优先读 [Client SDK](/zh/city-sdk-docs/packages/federation/client)
- 你只想知道账号登录怎么跑通:优先读 [@downcity/services](/zh/payments)
- 你只想看某个 custom service 怎么写:优先读 [Service 与 Action](/zh/city-sdk-docs/packages/federation/service-and-action)
## 最小可运行示例
下面这个例子说明 Federation 运行时最小长什么样:
```ts
import { AIService, Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
import { serve } from "@hono/node-server";
const database = new Database({ filename: "./.base/downcity.sqlite" });
const base = new Federation({ database });
const ai = new AIService();
ai.use({
id: "local-echo",
name: "Local Echo",
actions: {
text: async (ctx) => ({
id: crypto.randomUUID(),
role: "assistant",
parts: [
{
type: "text",
text: String(ctx.input.prompt ?? ""),
state: "done",
},
],
}),
},
});
base.use(ai);
await base.health();
serve({
fetch: (request) => base.fetch(request),
port: 43127,
hostname: "127.0.0.1",
});
```
这个例子里真正重要的是三件事:
1. `database` 决定 Federation 使用哪一个数据库 Adapter
2. `base.use(...)` 决定 Federation 暴露哪些能力
3. `base.fetch()` 决定你怎么把它接进 HTTP 层
## 生产启动与数据库准备
本地默认使用 `database_initialization: "automatic"`,首次初始化自动准备表、索引及系统数据。生产环境可以使用 `"prepared"`,将这些操作移出请求的启动路径。
部署进程应创建一个注册了完整 Service 的新实例,先调用 `await federation.prepare_database()`。该方法执行数据库准备、Service 准备及初始化验证,成功后写入准备版本标记;失败必须中止发布。随后上线的请求实例使用相同的 Service 配置及 `database_initialization: "prepared"`,只加载必要配置和密钥,缺少准备标记时拒绝启动。
`prepare_database()` 必须在该实例处理请求或调用 `health()` 之前执行,不应暴露成公开 HTTP 接口。跨实例 schema 变更仍需要受控发布,准备标记不替代应用自己的数据迁移历史。
全量迁移已有数据库时,先在空目标调用 `prepare_schema()`。该方法仅创建结构,不生成 Federation ID、配置或签名密钥,也不运行 Service 数据修复。导入并验证全部业务数据后,再调用 `prepare_database()` 完成运行准备,复用导入的原身份和密钥。
```ts
const federation = new Federation({
database,
database_initialization: "prepared",
on_startup_timing: ({ phase, duration_ms, ok }) => {
console.info({ phase, duration_ms, ok });
},
});
// 在调用 prepare_database() 或 health() 前注册全部 Service。
```
宿主通过定时任务调用 `await federation.maintenance()`,清理过期撤销记录和支持维护生命周期的 Service 数据。请求仍然校验凭证的有效期,安全性不依赖物理删除是否及时完成。自定义 Service 可分别通过 `on_prepare_database()` 和 `on_maintenance()` 扩展这两个阶段,`on_init()` 只处理实例运行所需的初始化。
启动计时事件只包含阶段名称、毫秒耗时和成功状态。启动失败的实例应由宿主退避重建整个容器,避免在部分安装完成的同一对象上重复注册路由。
## 场景一:Federation 自己就是主服务
如果你想让 Federation 自己作为主 HTTP 入口,最直接的理解方式就是上面的 `serve()`:
```ts
import { serve } from "@hono/node-server";
await base.health();
serve({ fetch: (request) => base.fetch(request), port: 3001, hostname: "127.0.0.1" });
```
这种方式适合:
- 先快速跑起一个独立 Federation
- 单独部署一个可复用的 AI backend
- 小团队先把 Federation 和业务后端拆成两个清晰边界
## 场景二:接进你自己的服务框架
如果你已经有现成的 HTTP 框架,更常见的是按请求转给 Federation:
```ts
export default {
async fetch(request: Request) {
return base.fetch(request);
},
};
```
这种方式适合:
- Cloudflare Workers
- 你已经有现成网关或 API server
- 想把 Federation 当成内部运行时,而不是单独开一个端口
### Cloudflare Workers 并发冷启动
同一个 Worker 部署可能同时启动多个 isolate。它们拥有独立内存,但共享同一个 D1,因此模块级 Promise 只能避免单个 isolate 重复初始化,不能充当全局锁。
Federation 会在共享数据库中原子初始化系统 env 和 Ed25519 user token signing key,并通过唯一约束保证全局只有一把 active signing key。如果数据库中存在旧版本并发启动遗留的多把 active key,启动时会保留创建时间最早的一把,并把其余 key 转为 retired。retired key 仍会发布到 JWKS,用于验证已经签发的 token。
## 场景三:本机 HTTP
如果 Federation 只需要被同一台机器上的可信进程访问,可以直接把标准 `fetch` handler 挂到你的本机 HTTP server:
```ts
import { Federation } from "@downcity/federation";
import { serve } from "@hono/node-server";
const base = new Federation({ database });
serve({
fetch: (request) => base.fetch(request),
hostname: "127.0.0.1",
port: 15315,
});
const federation_url = "http://127.0.0.1:15315";
```
然后 Embassy 可以直接连接这个 HTTP 地址:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url,
admin_token: administrator_session_token,
});
await embassy.admin.list_services();
```
本机 HTTP 和远程 HTTP 使用同一套身份模型:Embassy Admin 使用管理员会话 Token,需要用户身份的 Action 仍然必须携带正常 `user_token`。
如果 `AccountsService` 配置了 `local_login: true`,`/v1/accounts/providers` 会只返回本地登录方式。调用 `POST accounts.login/start` 并传入 `provider: "local"`、明确的 `client_type` 与 `bureau_id`,保存返回的 `login_id` 和仅供发起端保管的 `login_secret`。`POST accounts.login/result` 携带两者只查询状态;完成后通过 `POST accounts.login/exchange` 携带两者及稳定随机 `exchange_id` 领取 `user_token`,再用该 Token(其中包含 `bureau_id`)创建或更新 Embassy User。交换请求发生网络错误时复用原 `exchange_id`。
## 为什么只传 Database Adapter
`@downcity/federation` 负责的是跨运行时都应该稳定的逻辑:
- service 生命周期
- action 路由
- token 鉴权
- city / env / store 基础设施
而具体宿主环境只负责创建对应 Adapter:
- Node.js 可以用 `@downcity/database-sqlite` 或 `@downcity/database-postgresql`
- Cloudflare Workers 可以用 `@downcity/database-d1`
- Federation 启动时会把需要的 env 写入内置 `env` 表
这就是为什么同一套 runtime 模型可以复用到不同宿主环境,而不是每换一个平台就重写一套后端。
## 默认 Storage
如果你的 Federation 需要把运行期产生的外部文件转存到自有存储,可以注册默认 storage:
```ts
import { Federation, R2Storage } from "@downcity/federation";
const base = new Federation({ database });
base.storage(R2Storage({
bucket: env.DOWNCITY_STORAGE,
public_url_prefix: env.DOWNCITY_STORAGE_PUBLIC_URL_PREFIX,
}));
```
注册后,service 可以通过 `ctx.storage` 访问这套能力。内置 AI 图片任务会在 `image_fetch` 成功后自动把远程 file part URL 转存到默认 storage;如果转存失败,会保留上游原始 URL,不会让图片任务失败。
## 常用 API / 入口
这一页最相关的 Federation API 是:
- `new Federation({ database })`:创建 Federation 实例
- `base.use(...)`:挂 service、AIService 和官方服务
- `base.storage(...)`:注册默认文件存储后端
- `base.fetch(request)`:处理单次请求
- `base.health()`:触发初始化并返回健康信息
- `base.table(name)`:拿某张表的受控数据接口
如果你是从“怎么写能力”进入,下一页更关键的是 [Service 与 Action](/zh/city-sdk-docs/packages/federation/service-and-action)。
## 常见误解
### Federation 是 runtime,不是整个 SDK
Federation 是服务端长期运行容器。产品侧真正调用它用的是 `City` 客户端,见 [Client SDK](/zh/city-sdk-docs/packages/federation/client)。
### Federation 不是纯 HTTP 代理
它背后还持有:
- 内置表
- token / city / env 基础设施
- service 数据层
- hook 生命周期
### Federation 不等于某一个 AI provider
provider、model、service、custom service 都只是 Federation 上挂载的能力,不是 Federation 本身。
## 继续阅读
- 想知道 Federation 里到底挂的是什么,读 [Service 与 Action](/zh/city-sdk-docs/packages/federation/service-and-action)
- 想知道服务和扩展点怎么进去,读 [Hook 与服务挂载](/zh/city-sdk-docs/packages/federation/hooks-and-service)
- 想知道数据层怎么落地,读 [Store 与 Table](/zh/city-sdk-docs/packages/federation/store-and-table)
- 想看更底层 API,再读 [Federation API](/zh/city-sdk-docs/reference/federation)
### Database Adapter
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/database-adapters/
Federation 不接收裸 Drizzle db。它接收一个继承自 `@downcity/federation` 的 `Database` 实例,由 Adapter 统一拥有 Driver、事务、DDL 与连接生命周期。
## SQLite
```bash
pnpm add @downcity/federation @downcity/database-sqlite
```
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-sqlite";
const database = new Database({ filename: "./federation.sqlite" });
const federation = new Federation({ database });
```
本地测试可使用 `filename: ":memory:"`。文件数据库默认启用 WAL。
## Cloudflare D1
```bash
pnpm add @downcity/federation @downcity/database-d1
```
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-d1";
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise {
const database = new Database({ binding: env.DB });
const federation = new Federation({ database });
await federation.health();
return federation.fetch(request);
},
};
```
D1 Adapter 使用读取快照、冲突重试和原子 `batch()` 提供与其他运行时一致的 `context.transaction()`。事务内后续读取可以看到尚未提交的插入、更新和删除。
## PostgreSQL
```bash
pnpm add @downcity/federation @downcity/database-postgresql
```
```ts
import { Federation } from "@downcity/federation";
import { Database } from "@downcity/database-postgresql";
const database = new Database({ url: process.env.DATABASE_URL! });
const federation = new Federation({ database });
```
PostgreSQL Adapter 使用 postgres-js 与 Drizzle 原生事务。
## Service 看到什么
普通 Service 通过 `context.table()` 和 `context.transaction()` 访问数据库。需要参数化 SQL 的官方 Service 只能使用受限的 `context.database.query()` / `atomic()`;不会拿到 D1 binding、better-sqlite3 connection 或 postgres-js client。
## 生命周期
`Federation` 拥有传入的 Database。服务关闭时调用:
```ts
await federation.dispose();
```
Adapter 会幂等释放自己拥有的连接;释放后的数据库操作会失败。
### Service 与 Action
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/service-and-action/
`Service` 和 `Action` 是 `@downcity/federation` 里最重要的业务组织单位。
先记一句话就够了:
> Service 是一组长期存在的业务能力,Action 是真正被调用的具体入口。
## 这个概念在说什么
你最终给产品暴露的,不是一个“随便写的 handler”,而是 City 里统一管理的一组 action。
每个 Action 同时承担四个角色:
- 业务入口
- HTTP 路由节点
- hook 作用点
- 权限边界
所以理解 Service / Action,本质上就是理解 Downcity City 如何组织业务能力。
## 什么时候应该写 custom service
- 你要表达的是产品业务动作,而不是单纯模型推理
- 你要让前端、App、服务都走统一的业务入口
- 你希望一个能力能被 hook、鉴权、usage 和服务治理
典型例子:
- 改写内容
- 生成报告
- 执行工作流
- 处理上传后的业务逻辑
## 什么时候更适合先用 AIService
如果你只是想提供这些 AI 能力:
- `text`
- `stream`
- `image`
- `video`
优先从 `AIService` 开始会更自然,因为它已经替你处理了模型目录和 AI 输出约定。
## 最小 custom service 示例
```ts
import { Federation, Service } from "@downcity/federation";
const base = new Federation({ database });
const translate = new Service({
id: "translate",
name: "翻译",
});
translate.action("zh2en", async (ctx) => {
return {
text: await runTranslate(String(ctx.input.text ?? ""), "en"),
};
});
translate.action("list", async () => {
return {
items: ["zh2en"],
};
}, {
method: "GET",
});
base.use(translate);
```
这里你做了三件事:
1. 创建一个 service
2. 往里面注册多个 action
3. 把整个 service 挂到 City
## 路由会怎么暴露
上面的 Action 会自动进入统一路由空间:
```txt
POST /v1/translate/zh2en
GET /v1/translate/list
```
这也是为什么产品端最后统一都能通过 `@downcity/federation` 调:
```ts
const result = await client
.service("translate")
.action("zh2en")
.invoke<{ text: string }>({
text: "你好 Downcity",
});
const list = await city.service("translate").get("list");
```
## 常见场景
### 场景一:产品业务 Action
```ts
const writer = new Service({ id: "writer", name: "内容写作" });
writer.action("draft", async (ctx) => {
return {
title: "Draft",
topic: ctx.input.topic,
};
});
```
适合:
- 业务工作流
- 自定义生成结果
- 你自己的 SaaS 能力
### 场景二:管理侧 Action
```ts
writer.action("remove", async (ctx) => {
await removeDraft(String(ctx.input.id));
return { ok: true };
}, {
auth: ["admin"],
});
```
适合:
- 只能由可信后端执行的动作
- 运维、删除、配置变更
### 场景三:guest 入口
```ts
writer.action("webhook", async (ctx) => {
return { accepted: true };
}, {
auth: [],
});
```
适合:
- 第三方 webhook
- 登录前入口
- 回调型 Action
## AIService 和普通 Service 的关系
AIService 不是另一套协议,而是 City 里一类更偏 AI 的 service。
它额外提供:
- 模型目录
- 多 modality 调用
- OpenAI 兼容入口
- 更适合 `UIMessage` / stream 的返回约定
所以你可以这样区分:
- 直接模型推理:优先 `AIService`
- 业务动作或服务动作:优先 `Service`
## 常用 API / 入口
这一页最相关的调用入口有:
- `new Service({ id, name })`
- `service.action(name, handler, options?)`
- `options.method: "GET" | "POST"`
- `options.auth: ["user"] | ["admin"] | []`
- `base.use(service)`
- `city.service(id).action(name).invoke(input)`
- `city.service(id).get(path, query?)`
## 常见误解
### Action 不只是一个函数
它最终会成为:
- HTTP 路由
- 鉴权边界
- hook 作用点
- usage 记录点
### service 和 custom service 不需要两套调用方式
对产品端来说,它们最终都统一走:
```ts
city.service("service_id").action("action_id").invoke(...)
```
### GET / POST 的差别不是语法装饰
读操作更适合 GET,例如:
- `me`
- `list`
- `summary`
写操作、执行动作更适合 POST,例如:
- `login`
- `draft`
- `checkout/create`
## 继续阅读
- 想知道 hook 和 service 怎么围绕 Action 工作,读 [Hook 与服务挂载](/zh/city-sdk-docs/packages/federation/hooks-and-service)
- 想知道产品端怎么调用这些能力,读 [@downcity/federation](/zh/city-sdk-docs/packages/federation/federation)
- 想看更底层 API 选项,读 [City API](/zh/city-sdk-docs/reference/federation)
### Hook 与服务挂载
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/hooks-and-service/
Downcity 很多共享能力不是靠“把代码复制到每个 service 里”完成的,而是通过 hook 和 service 进入 City。
## 这两个概念分别在解决什么
可以先这样分工:
- hook:给已有调用链插入规则
- service:给 City 增加一整组长期复用能力
所以你看到的 usage、账号、支付,并不是“额外开一套系统”,而是作为扩展点进入同一套 Federation。
## 什么时候该先想 hook
- 你想在某类调用前后加规则
- 逻辑是横切的,不想写进每个 action
- 能力只在当前项目里使用,不一定要独立成 package
典型例子:
- 额度判断
- 日志记录
- 风控校验
- 调用后扣减余额
## 什么时候更适合做 service
- 能力需要跨多个产品复用
- 不只是前后置逻辑,而是一整组 service / 表 / 管理入口
- 你希望产品侧能长期通过统一 service 调它
典型例子:
- `@downcity/services`
- `@downcity/services`
- `@downcity/services`
## 先理解 hook 的三层位置
```txt
global
-> service
-> action
```
含义分别是:
- global:所有 service / action 共享
- service:某个 service 下所有 action 共享
- action:只对一个具体 action 生效
这三层不是为了增加复杂度,而是为了让你把规则放在最合适的位置,而不是一股脑写死到业务函数里。
## 最常见的 service 挂载示例
```ts
import { AIService, Federation } from "@downcity/federation";
import {
AccountsService,
CreditsService,
PaymentService,
googleAccountsProvider,
stripePaymentProvider,
UsageService,
} from "@downcity/services";
const base = new Federation({ database });
const credits = new CreditsService();
const ai = new AIService({ credits });
base.use(new AccountsService({
token_ttl: "7d",
providers: [
googleAccountsProvider(),
],
}));
base.use(credits);
base.use(ai);
base.use(new PaymentService({
resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000 }),
on_paid: async (record) => {
await credits.topup({
card: { kind: "primary", user_id: record.user_id },
credits: record.credits,
source: "payment",
ref: record.payment_id,
idempotency_key: `payment:${record.payment_id}`,
});
},
providers: [
stripePaymentProvider({
secret_key: process.env.STRIPE_SECRET_KEY,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
}),
],
}));
base.use(new UsageService({
ai_usage_reader: ai,
credits_usage_reader: credits,
}));
```
这段代码背后的意思是:
- `new AccountsService()` 给 City 增加 `accounts` service 容器
- `AccountsService` 会自动挂载 `/providers`、统一登录入口、`me`、管理侧读取、better-auth passthrough 与 OAuth callback 入口;具体登录方式只有在对应 provider 挂载且配置满足后才会开放
- `new PaymentService()` 给 City 增加统一支付入口,用来暴露当前可用的支付方式
- `new UsageService(...)` 聚合 `AIService` 技术用量与 `CreditsService` 已入账消费,并提供当前用户查询入口
- `stripePaymentProvider()` 给统一 Payment 服务增加 Stripe Checkout、webhook 和支付结果同步
## service 挂进来后产品端怎么调
从产品视角看,service 最终会变成统一 service:
```ts
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
client_type: "cli",
bureau_id: "demo",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
input: {
email: "user@example.com",
password: "password123",
},
});
const status = await guest.service("accounts").action("login/result").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
});
// 邮箱须先验证;交换请求发生网络错误时复用该 ID。
const exchange_id = crypto.randomUUID();
if (status.status !== "done") throw new Error("登录尚未完成");
const session = await guest.service("accounts").action("login/exchange").invoke({
login_id: login.login_id,
login_secret: login.login_secret,
exchange_id,
});
const usage = await user.service("usage").get("me", {
from: "2026-08-01",
to: "2026-08-31",
timezone: "Asia/Shanghai",
});
const recent_usage = await user.service("usage").get("me/recent", {
limit: 20,
});
const methods = await guest.service("payment").get("methods");
const checkout = await user.service("payment").action("checkout/create").invoke({
method_id: "stripe",
topup_amount_minor: 500,
idempotency_key: "order_123",
});
```
也就是说,service 并不会让产品端学第二套调用协议。
## hook、service 的关系
可以把它们想成一条链:
```txt
service / action
-> hook 在调用前后加规则
-> service 提供可复用 service、表和管理能力
-> client 统一通过 /v1/* 调用
```
这条链的好处是:
- 业务能力能复用
- 规则能集中治理
- 前端和后端的调用模型保持统一
## 常用 API / 入口
这一页最常用的公共入口其实很简单:
- `base.use(service)`:把 service 挂进 City
- `guest.service("accounts")...`
- `guest.service("payment").get("methods")`
- `user.service("usage")...`
- `admin.service("payment").get("payments")`
如果你是从扩展角度思考,最重要的不是记很多 API,而是先判断“这是 hook 问题还是 service 问题”。
## 常见误解
### service 不是单纯的 npm 包
它代表的是一整组进入 City 的能力边界,通常还伴随:
- service
- 数据表
- 管理接口
- 可选 hook 逻辑
### hook 不等于随便加中间件
hook 适合放横切治理逻辑,不适合把整个业务动作都塞进去。
### service 不替代 City
service 负责把能力挂进 City,真正的产品调用入口仍然是 [@downcity/federation](/zh/city-sdk-docs/packages/federation/federation)。
## 继续阅读
- 想看 city 端怎么统一调用 service,读 [@downcity/federation](/zh/city-sdk-docs/packages/federation/federation)
- 想看账号能力,读 [@downcity/services](/zh/payments)
- 想看 usage 记录,读 [@downcity/services](/zh/payments)
- 想看 Stripe 一次性充值,读 [@downcity/services](/zh/payments)
### Store 与 Table
Source: https://downcity.ai/zh/city-sdk-docs/packages/federation/store-and-table/
Downcity 最常被看见的是 AI 调用,但它能长期稳定运行,靠的是一套明确的数据层。
## 这个概念在说什么
很多 Federation 能力都不是纯内存状态,它们最终都必须落到数据事实:
- city 是否 active
- 某个 token 属于谁
- 某个 env key 当前值是什么
- 某次 service 调用是否已记录 usage
- 某个用户是否已经拿到 entitlement
这就是为什么 `@downcity/federation` 里不仅有 HTTP 路由,还有 store / table 这一层。
## 什么时候你需要直接关心这一层
- 你在排查 service 有没有真的写表
- 你要把 Federation 数据和自己的业务表联动
- 你想从代码里读写某些受控表
- 你在做运维排查,不想只看 HTTP 返回
## `base.table()` 代表什么
`base.table(name)` 不是“随便暴露数据库”,而是 Federation 对底层数据库能力的受控暴露。
它给你的不是随意 SQL 字符串,而是围绕 Federation 生命周期统一管理的表接口。
## 最小示例:读写 Federation 表
```ts
const cities = await base.table("cities");
const env = await base.table("env");
const notes = await base.table("notes.notes");
await notes.insert({
id: "note_1",
title: "First note",
status: "draft",
});
const draftNotes = await notes.select({
status: "draft",
});
```
这段代码说明三件事:
1. Federation 内置表可以直接拿
2. 你自己的 service 表也可以通过统一入口拿
3. 数据操作最终受 runtime 和 City 生命周期治理
## 你通常会碰到哪些表
### Federation 内置表
- `cities`
- `env`
它们支撑的是:
- 产品状态
- 运行时环境变量
- Federation 的管理基础设施
### service 表
不同 service 会带自己的表,例如:
- accounts 相关用户 / session 表
- usage 事件表
- Stripe entitlement / webhook 事件表
### 你自己的业务表
如果你在 service 里定义了业务表,也会进入同一个统一 runtime 数据层。
## 常见场景
### 场景一:确认数据到底有没有落进去
```ts
const usageEvents = await base.table("service_usage_events");
const rows = await usageEvents.select();
```
这类场景常见于:
- usage 为什么看起来没记上
- Stripe webhook 进来了没有
- 账号注册后表里有没有数据
### 场景二:程序里做受控更新
```ts
const envTable = await base.table("env");
await envTable.update({
where: { key: "OPENAI_API_KEY" },
values: { value: "new-secret" },
});
```
适合:
- 可信环境里的受控维护
- 内部脚本
- 管理后台后端逻辑
### 场景三:删除或清理某类数据
```ts
const notes = await base.table("notes.notes");
await notes.delete({ id: "note_1" });
```
适合:
- 业务清理
- 测试环境重置
- 服务联动逻辑
## 常用 API / 入口
这一层最重要的 API 很少:
- `await base.table(name)`
- `table.select(where?)`
- `table.insert(values)`
- `table.update({ where, values })`
- `table.delete(where)`
如果你想看更底层方法签名,去读 [Runtime API](/zh/city-sdk-docs/reference/runtime-api) 和 [City API](/zh/city-sdk-docs/reference/federation)。
## 常见误解
### Federation 不只是 API 代理
它后面还有长期存在的数据层,很多管理能力都建立在这些表上。
### `table()` 不是让你跳过 Federation 设计
更推荐先通过:
- service
- service
- Embassy Admin
来表达业务入口;`table()` 更适合可信环境和受控排查。
### 不是所有表都应该直接给前端碰
前端产品应该继续走 `Embassy User` / `Embassy Admin`,而不是直接访问底层表。
## 继续阅读
- 想知道数据库是怎么接进来的,读 [Federation](/zh/city-sdk-docs/packages/federation/federation)
- 想在终端里查这些表,读 [CLI](/zh/city-sdk-docs/packages/cli)
- 想看 service 会落哪些表,结合对应 package 页一起读
### HTTP 与 RPC
Source: https://downcity.ai/zh/city-sdk-docs/packages/city/transport/
`City` 统一拥有一个 HTTP transport 和一个 RPC transport。两个端口都按 `agent_id`
路由到同一组 Agent,并通过 `workspace_id` 选择 City 持有的 Workspace;Session 仍由 Agent 创建。
```ts
await city.listen({
http: { host: "127.0.0.1", port: 5314 },
rpc: { host: "127.0.0.1", port: 15314 },
});
await city.close(); // 只关闭 transport;Agent 仍由创建它的宿主释放
```
如果需要分别控制生命周期,也可以直接启动 City 的单个 transport:
```ts
const http = await city.http({ host: "127.0.0.1", port: 5314 });
const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 });
```
HTTP 地址是 `http://host:port/agents//workspaces/`,RPC 地址是
`rpc://host:port//`。不再提供 Agent 级 transport 构造器,外部服务统一属于 City。
### @downcity/city/local
Source: https://downcity.ai/zh/city-sdk-docs/packages/local/
`@downcity/city/local` 提供 CLI 与 Desktop 共用的本地宿主能力,包括 SQLite Adapter、
路径、产品 Schema、Repository、环境装配与 Power Loader。
```ts
import {
LocalDatabase,
AgentRepository,
WorkspaceRepository,
ensure_local_schema,
} from "@downcity/city/local";
const database = new LocalDatabase({ filename: database_path });
ensure_local_schema(database);
const workspaces = new WorkspaceRepository(database);
const agents = new AgentRepository(root_path);
```
CLI 与 Desktop 在各自组合根中创建这些依赖,读取配置后显式调用 `new Agent()`。
该 local 子入口不创建 Agent、Workspace、Model 或 City。
Workspace 与平台设置以明文 JSON 保存,依靠本地数据库文件的 `0600` 权限进行用户级隔离。
### CLI 快速开始
Source: https://downcity.ai/zh/city-sdk-docs/packages/cli/quickstart/
`downcity` 包会安装两组命令:
- `fed` / `downfed`:管理和部署 Federation。
- `city` / `downcity`:管理本机 Agent、本地 Power、chat/gateway,以及当前本机登录到哪个 Federation。
## 安装
```bash
npm i -g downcity
```
查看版本:
```bash
fed -v
city -v
```
## 管理 Federation:用 fed
当你要创建、部署或管理共享后端时,使用 `fed`。
```bash
fed
fed create .
fed create ./edge-fed --template cloudflare-workers
fed create ./custom-fed --template git://example.com/custom-fed.git
fed deploy
fed deploy --dry-run
fed deploy --verify
```
`fed create` 默认生成 Local Node.js + SQLite 项目。`--template` 可以选择 `cloudflare-workers` 或 Git 模板。项目身份和部署方式统一写在 `federation.json` 中。
`fed deploy` 表示部署当前项目,不区分本地与云端。本地目标会从 `12314` 开始分配端口并启动受管进程;首次部署时 CLI 交互式设置管理员 ID 和密码,后续普通部署复用现有管理员。重复部署同一 `fed_id` 时会用最新代码替换旧实例,不会修改管理员。Cloudflare Workers 目标会准备 D1、Queue 和 R2,再发布 Worker,并通过管理员登录创建有期限的 Session Token 保存到本地 registry。
管理已部署 Federation:
```bash
fed server add
fed server
fed query GET /health
fed query GET /v1/ai/models
fed query POST /v1/ai/image/result --data '{"job_id":"..."}'
```
所有 Federation URL、active 状态、管理员 ID、管理员 Session Token、本地 PID、端口、日志和部署状态都保存在系统级 Federation registry,不依赖当前路径。downfed 没有默认 Federation;`fed deploy` 只登记或更新实例,不会修改 `active_server_url`。用户在 downfed 界面明确打开 Federation 后,它才成为 active。`fed query` 在存在有效管理员 Session 时自动携带 `Authorization: Bearer`,Session 失效后要求重新登录。
## federation.json
```json
{
"schema": 1,
"type": "federation",
"id": "fed_example",
"name": "example",
"entry": "src/index.ts",
"deployment": {
"target": "local",
"scripts": {
"build": "pnpm typecheck",
"deploy": "pnpm start"
}
}
}
```
`deployment.scripts` 是可选覆盖。没有配置时使用目标内置部署器;配置 `build` 或 `deploy` 时只替换对应阶段。
## 管理本机 Agent 和登录态:用 city
当你要让本机连接某个 Federation、登录用户态账号、运行 Agent 或进入 chat 时,使用 `city`。
```bash
city
city federation status
city federation join https://your-federation.example.com
city federation login
city agent create .
city agent list
city on
city agent chat
```
`city agent chat` 会持续订阅当前 Session,并实时合并 Message、Part、文本、reasoning、Tool、Interaction 和配置状态。打开 Agent 会进入其最近一次 Session,并跟随该 Session 所属 Workspace;该 Agent 还没有历史时,Chat 在入口 Workspace 新建 Session。顶部会显示当前 Session 的模型名称;使用 `/model` 可从当前用户可调用的模型目录中选择模型,也可通过 `/model ` 直接切换。模型切换只影响当前 Session,并通过 Session Mutation 更新界面,不会修改 Agent 默认模型。使用 `/session` 切换会话时,TUI 会先订阅新 Session,再加载完整快照并合并期间收到的事件。模型提供可见 reasoning 文本时,TUI 会按 Assistant Part 顺序弱化展示;只有 reasoning metadata 时不会生成虚假的推理内容。
`city federation ...` 只管理本机的 Federation 成员资格和用户登录态,不负责部署 Federation。
CLI City 每次启动都有独立实例 ID。`city off` 会同时校验 PID 和实例 ID;如果 pid/meta 已过期或 PID 已被其他进程复用,CLI 不会向无关进程发送信号。
选择 GitHub、Google 等 OAuth 登录方式后,CLI 会始终输出 `authorization_url`。本地图形环境会同时尝试打开默认浏览器;通过 SSH 登录 VPS 或运行在无图形环境时,请复制该链接到你自己的浏览器中完成授权,CLI 会继续等待登录结果。
## federation.json 里的默认存储
Cloudflare Workers Federation 项目可以在 `federation.json` 里声明默认 storage。当前支持 R2:
```json
{
"deployment": {
"target": "cloudflare-workers",
"resources": {
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "downcity-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
部署时 `fed deploy` 会先检查同名 R2 bucket 是否存在;不存在且不是 dry-run 时会自动创建。随后 CLI 会把它写入临时 `wrangler.toml` 的 `[[r2_buckets]]`,并把 `public_url_prefix` 写成 Worker var。
## 常见误解
### `city deploy` 不是部署入口
Federation 部署入口是 `fed deploy`。
### `city create` 不是 Federation 项目入口
Federation 项目创建入口是 `fed create`。本机 Agent 项目创建入口是 `city agent create`。
### fed 和 city 不是同一个角色
- `fed`:管理共享后端和 admin 侧能力。
- `city`:管理本机 Agent 和用户态连接。
## 继续阅读
- 要部署 Federation,读 [部署到 Cloudflare Workers](/zh/city-sdk-docs/operations/deploy-cloudflare-workers)
- 要排查本机状态,读 [运维与排查](/zh/city-sdk-docs/packages/cli/operations-and-debug)
- 要理解 Federation runtime,读 [Federation 运行时](/zh/city-sdk-docs/packages/federation/federation)
### Models 与 Tables
Source: https://downcity.ai/zh/city-sdk-docs/packages/cli/models-and-tables/
CLI 最常被用来做两件事:
- 看模型
- 看表
## 为什么 models 是第一排查入口
很多“AI 调不通”的问题,最后并不是 provider 本身坏了,而是模型配置层出问题:
- model 有没有注册进去
- 配置的 model id 是不是你预期的那个
- 数据库里的模型记录是否正确
这时先开 CLI 通常比写临时脚本更快。
## 为什么 tables 同样关键
当你排查这些能力时,最后往往都会落到表:
- `cities`
- `env`
- usage 事件表
- accounts 相关表
- entitlement / webhook 相关表
也就是说,Downcity 很多问题最终都可以归结成一句话:
> 数据事实到底是什么?
## 最常见的模型相关入口
```bash
city
city federation login
city federation status
```
适合:
- 查看 Federation AIService 暴露出的模型
- 选择 City base 并通过 City 登录
- 在启动本机 Agent 前确认 City 连接设置可用
## 最常见的表排查场景
### 场景一:service 是否真的写了表
例如你接了:
- `@downcity/services`
- `@downcity/services`
- `@downcity/services`
这时你最关心的往往不是“文档里说会写”,而是“我的这个 City 里现在到底有没有这些表和数据”。
CLI 正适合先肉眼确认。
### 场景二:usage / payment / auth 为什么看起来没生效
先看表再猜逻辑,通常比只看 HTTP 报错更高效。
你会更快判断问题是在:
- 调用没有进来
- service 没启用
- 表没有创建
- 数据写入失败
## 一个常见工作流
```bash
city
# or start a local agent
city agent start .
city federation status
```
然后在交互式面板里继续看:
- tables
- schema
- rows
这个工作流适合第一次接 City 或第一次接 service 后的确认。
## 它和 City 数据层的关系
CLI 本身不替你定义数据模型,它只是把 Federation里已经存在的数据层摊开给你看。
这也是为什么这页要和 [Store 与 Table](/zh/city-sdk-docs/packages/federation/store-and-table) 一起理解:
- `@downcity/agent` 负责受控数据层
- `downcity` 负责人工查看和排查入口
## 常见误解
### 看表不是“低级排查”
在 City、service、usage、payment 这类场景里,看表往往是最快定位事实的方式。
### CLI 不是数据库 IDE 替代品
它更像 City 专用运维入口,而不是通用数据库管理器。
### 只看模型不够
模型、service 表、usage 事件、env 都可能共同影响一个产品请求,所以很多时候需要把 models 和 tables 一起看。
## 继续阅读
- 想知道 CLI 在运维里怎么用,读 [运维与排查](/zh/city-sdk-docs/packages/cli/operations-and-debug)
- 想理解这些表的来源,读 [Store 与 Table](/zh/city-sdk-docs/packages/federation/store-and-table)
### 运维与排查
Source: https://downcity.ai/zh/city-sdk-docs/packages/cli/operations-and-debug/
CLI 和 `Embassy Admin` 都能帮你排查 Downcity,但它们适合的工作不一样:`fed/downfed` 面向 Federation 管理,`city/downcity` 面向本机 Agent 与本机连接,`Embassy Admin` 面向可信程序调用。
## 先给一个最简单的判断标准
如果你想的是:
- “我现在就想看看事实是什么”
优先 CLI。
如果你想的是:
- “这个动作以后要长期存在于系统里”
优先脚本化或 `Embassy Admin`。
## CLI、Embassy Admin、数据库直查各自适合什么
### 更适合 CLI 的情况
- 本地开发机上快速看数据
- 排查某个 service 到底有没有写表
- 想看模型记录、schema、rows
- 不想为了一个一次性问题先写脚本
### 更适合 `Embassy Admin` 的情况
- 要把动作放进业务后端
- 要自动化签 token
- 要做正式管理流程
- 要把管理动作暴露给内部工具或后台
### 更适合直接数据库排查的情况
- 你已经确认问题落在更底层数据库行为
- 需要做更细粒度 SQL / 运维层检查
- CLI 已经帮你缩小问题范围,但还要看更底层事实
## 最常见的 CLI 排查动作
```bash
city
fed
city federation status
city agent list
```
第一条适合打开交互入口,后两条适合确认 City 连接和本机 Agent 运行状态。
## CLI 与 Desktop 的 City 互斥
CLI 和 Desktop 使用同一组 `~/.downcity` 数据,但各自拥有独立的 City 进程。City
启动时会登记当前宿主;如果另一端已经运行,启动方会先询问是否关闭已有 City 并接管。
取消接管不会修改已有宿主。City 关闭后,Agent、Power 和 Session 数据仍保留在共享数据目录。
## 用户系统 Dashboard
打开 `fed`,选择一个已保存的 Federation,然后在 Admin 菜单中进入 `用户系统 Dashboard`。
CLI 会读取已安装官方服务的 admin 数据,并在终端内本地计算 Dashboard。
Dashboard 覆盖:
- `accounts`:注册用户、新注册、活跃会话
- `usage`:活跃用户、service 调用事件
- `payment`:付费用户、首付用户、支付订单、按币种聚合的真实收入
- `balance`:已入账 credits、当前余额 credits
如果某个服务没有安装,Dashboard 会把对应模块标记为不可用,同时继续展示其它已安装服务的数据。
收入只来自成功支付记录里的真实 `amount_minor`;credits 会单独作为余额和充值单位展示,不再当成 revenue。
## 一条常见排查链路
当你遇到“调用不对、usage 没记、payment 没生效”时,通常可以按这个顺序走:
1. 先确认 Federation 是否连对数据库
2. 再确认 models / tables 是否存在
3. 再确认 service 是否真的写入数据
4. 最后才去怀疑更深的业务逻辑
这比一开始就盲猜代码路径更快。
## 例子:usage 看起来没记录
这时更合理的第一步通常不是改代码,而是先确认事实:
- usage service 有没有启用
- usage 表有没有创建
- 当前数据库是不是你以为的那个
- 有没有任何事件被写进去
CLI 正适合这一步。
## 例子:支付后还是没权限
优先查这几层事实:
- webhook 有没有到 Federation
- webhook 事件有没有落表
- entitlement 有没有变成 active
这时 CLI 比直接去改前端拦截逻辑更有效。
## 常见误解
### CLI 不只是开发时用一次
只要你管理多个产品、多个 service、多个模型,CLI 都会是高频运维入口。
### `Embassy Admin` 不是“更高级的 CLI”
两者不是上下位关系,而是两种不同形态:
- `Embassy Admin`:可信程序调用
- CLI:人工终端排查
### 直接查数据库不应成为第一反应
很多时候 CLI 已经足够把问题缩小到很小范围。
## 继续阅读
- 想知道 `fed` / `city` 的基础入口,读 [CLI 快速开始](/zh/city-sdk-docs/packages/cli/quickstart)
- 想重点看模型和表,读 [Models 与 Tables](/zh/city-sdk-docs/packages/cli/models-and-tables)
- 想理解背后的数据层,读 [Store 与 Table](/zh/city-sdk-docs/packages/federation/store-and-table)
### @downcity/agent
Source: https://downcity.ai/zh/city-sdk-docs/packages/agent/
`@downcity/agent` 提供 Agent、AgentSessions、Session 与执行内核。City 组合根和 RemoteAgent 由 `@downcity/city` 提供。
Agent 不持有 Power;City 负责 Power 唯一实例、配置投影与生命周期,Power 作者协议位于
`@downcity/city/power`。Workspace 资源由 `@downcity/city` 提供。
```ts
const city = new City({ workspaces: [workspace] });
const agent = new Agent({ id: "assistant" });
city.agents.add(agent);
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "你好" });
const result = await turn.finished;
```
使用 Power 前应先通过 `CityOptions.powers` 或 `city.powers.add()` 向 City 添加实例;
`city.agents.add()` 只登记 Agent,City 的全部 Power 会自动对它可用。
## 读取每轮文件改动
绑定 Workspace 的 Session 会把当前 Turn 通过内置结构化 `write`、`edit` 工具成功提交的文件编辑
写入最后一条 Assistant Message。Diff 直接来自文件工具的原子修改事实,不扫描 Turn 首尾工作树,
因此 Shell、脚本、外部编辑器和其他 Session 的修改不会进入该结果;非 Git Workspace 同样支持。
文件编辑使用 `data-session-turn-file-diff` data part 持久化,包含逐文件状态、增删行数和 unified diff。
可以使用公开解析函数安全读取:
```ts
import { read_session_turn_file_diff_data } from "@downcity/agent/session";
const page = await session.messages();
for (const message of page.items) {
if (message.type !== "assistant") continue;
for (const part of message.parts) {
if (part.type !== "data") continue;
const file_diff = read_session_turn_file_diff_data(part);
if (file_diff) console.log(file_diff.files, file_diff.additions, file_diff.deletions);
}
}
```
## Group 与 GroupSession
`Group` 是具有集体主体性的协作主体,拥有自己的 `model` 和 `GroupSession` 集合。`Group.model` 专门用于理解群聊意图并选择投递成员;默认使用 AI 调度。调度由 GroupSession 自身拥有,负责根据 Group 目标、当前对话和成员的 `id/name/description` 计算有限阶段计划,并统一处理调度 Turn 排队、持久化和停止。Agent 的 `description` 是供展示和语义选择使用的能力简介,不会替代决定 Agent 行为的 `instruction`。AI 调度在同一 Turn 内通过强制的 `dispatch_group` tool call 提交计划;如果模型先返回普通文本或无效参数,调度策略会把协议错误交还模型纠正,仍未获得有效调用时才记录明确失败。系统不会自动切换到另一套调度规则;如需自定义行为,应显式提供 `dispatch_strategy`,并让异步策略响应输入中的 `abort_signal`。成员执行仍使用成员 Agent 的 `AgentSession`,不会把私有上下文、Power
和工具调用复制进群聊历史。`GroupSession` 的 `prompt()` 只负责立即写入用户消息并启动异步调度;多个调用可以同时进入。首条用户消息落盘后,GroupSession 会使用 `Group.model` 异步生成短标题并写入自身 `meta.json`,不会阻塞调度。标题是独立的 canonical metadata,`preview_text` 仍只表示最后一条消息摘要;后续消息不会改写标题。GroupSession 只运行一个全局 auto dispatch,在当前成员执行完成后统一判断下一步;消息、标题和 Group/成员运行态统一通过
`subscribe()` 订阅,其中标题事件为 `{ type: "title", title }`。
`dispatch_group` 提交 `reason`、`stages` 和 `next`。`stages` 按顺序执行,同一阶段的 `assignments` 并行执行;每个 assignment 都包含成员 ID 和该成员要直接完成的具体任务。例如:
```json
{
"reason": "先形成方案,再基于方案做质量审查",
"stages": [
{
"assignments": [
{ "member_id": "architect", "instruction": "设计实现方案并说明关键取舍" }
]
},
{
"assignments": [
{ "member_id": "reviewer", "instruction": "基于上一阶段方案识别风险并给出结论" }
]
}
],
"next": "stop"
}
```
同一个成员可以出现在不同阶段,但同一阶段内不能重复。已知的连续步骤应一次放入多个 stages;`next: "continue"` 只用于下一步成员选择必须依赖本轮真实输出的场景。当前对话已经满足用户意图时使用空 `stages` 和 `next: "stop"`;所列 stages 执行后即可完成时也使用 `stop`。调度次数上限与重复路径检测只是异常熔断,不是正常停止条件。
调度器是“谁执行什么任务、何时停止”的唯一决策边界。成员一旦收到 assignment,就直接完成该任务,不会在自己的 AgentSession 中再次判断是否需要发言。被选中的成员未生成有效文本时,GroupSession 会把它记录为执行失败,并停止后续阶段。
自定义 `DispatchStrategy` 接收 `group`、`trigger`、`current_message`、`history`、`pending_messages`、成员画像和 `abort_signal`。其中 `history` 已排除本批新增消息,成员画像只包含 `agent_id/name/description`,不会暴露完整 Agent:
```ts
const dispatch_strategy: DispatchStrategy = {
decide_dispatch({ current_message, members }) {
return {
reason: `由 ${members[0].name} 处理当前请求`,
stages: [{
stage_id: `reply-${current_message.id}`,
assignments: [{
member_id: members[0].agent_id,
instruction: "直接完成用户当前请求",
}],
}],
terminal: true,
};
},
};
```
```ts
const lead = new Agent({ id: "lead", name: "负责人", description: "负责拆解目标并形成实现方案", model });
const reviewer = new Agent({ id: "reviewer", name: "审查员", description: "负责审查质量与风险", model });
city.agents.add(lead);
city.agents.add(reviewer);
const group = new Group({
id: "delivery-team",
model,
members: [lead, reviewer],
});
city.groups.add(group);
const group_session = await group.sessions.create({ workspace });
const result = await group_session.prompt({ query: "请完成实现并让 reviewer 审查改动" });
console.log(result.turn_id, result.success);
const sessions = await group.sessions.list();
const restored = await group.sessions.get(sessions[0].id, { workspace });
await restored.rename("交付评审");
```
`Workspace` 仍然是 City 提供的资源。Workspace 只注入具体的 GroupSession,再由 GroupSession 注入成员 AgentSession;它不属于 Agent、
Group,也不决定成员如何协作。加入带持久化 Storage 的 City 后,GroupSession 会保存自己的 Workspace ID、消息摘要、成员 AgentSession ID 映射、传播检查点,以及 `dispatch/turns.jsonl` 中的调度 Turn 日志;
进程中断后会根据已落盘阶段重新执行尚未完成的 user dispatch,或继续尚未完成的 auto dispatch。调用 `group_session.stop()` 会同时中断当前调度模型调用、取消排队调度并停止成员 AgentSession;主动停止不会写入调度失败消息;
`sessions.list()` 的摘要直接返回持久化 `title` 与 `preview_text`;`sessions.get(id)` 才恢复完整消息和成员会话引用。调用 `group_session.rename(title)` 会持久化手动标题并停止后台自动标题覆盖它。
### @downcity/city/workspace
Source: https://downcity.ai/zh/city-sdk-docs/packages/workspace/
`@downcity/city/workspace` 提供 Agent 执行任务时使用的 Workspace 实现;常用构造器也从
`@downcity/city` 根入口导出。Workspace 拥有项目路径、rooted 文件
能力、文件与搜索工具、环境变量和可选 Shell,但不拥有 Agent 身份、Power、Session、存储
或模型调用。
```bash
pnpm add @downcity/city @downcity/sandbox-microsandbox
```
```ts
import { Agent, City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
env: { NODE_ENV: "development" },
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
});
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
const session = await agent.sessions.create({ workspace });
```
## 所有权
- `Workspace` 拥有项目资源与可选 Shell。
- `Shell` 持有 Sandbox Provider、当前 Workspace 的持久 Sandbox 与 Shell Sessions。
- `City` 持有登记的 Workspace,但不理解其执行后端。
- `Agent` 拥有身份、指令、模型、自定义工具和 Session;`City` 拥有 Power。
- `AgentSessions` 拥有 Session。Workspace 只是 Session 可以使用的一种资源。
- Shell 是 Workspace 能力,不再存在独立的 `@downcity/shell` 包。
一个 Agent 可以在多个 Workspace 资源中创建 Session。Workspace 可以登记在 City 内,也可以由
宿主在 City 外管理;Agent 私有 Session 与 City 分配的 Agent/Power 执行数据始终隔离。
结构化 `write`、`edit` 工具会在文件原子提交后,通过 `WorkspaceToolActionResult.effects` 报告
`workspace.file_mutation`。Effect 不会作为 Tool output 发送给模型;Agent Session 只在当前 Turn
中按顺序收集,并在收口时将文件修改投影为 Assistant data part。Shell 与外部编辑不会产生该 Effect。
## 存储
City 提供不理解业务的底层 Storage Provider,默认实现是内存存储;需要持久化时由宿主注入本地
文件存储。使用本地存储时,运行状态集中保存在项目目录之外:
```text
~/.downcity/agents//
├── sessions/
└── powers/
```
`StorageProvider` 不理解任何 Agent 领域语义。Agent runtime 解释 Session 数据,City Power
runtime 解释 Power 数据;Workspace 不创建或拥有这份存储。项目 File/Search 工具始终限制在真实项目目录内。
Edge Adapter 应从 `@downcity/type/workspace` 导入 `WorkspaceRuntime` 与协议类型,避免加载
Node.js 文件系统、PTY 和本地 Shell 实现。
### AIChannel 与 Provider Adapter
Source: https://downcity.ai/zh/city-sdk-docs/service/ai/provider/
`AIChannel` 只拥有 Federation 到上游模型的执行边界。它接收 `AIChannelStreamInput`,返回 `ReadableStream`;fallback、计费、Session 与工具执行都不属于 Channel。
## OpenAI-compatible 上游
```ts
import {
AIChannel,
AIService,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com/v1",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const channel = new DeepSeekChannel();
const ai = new AIService();
ai.use(channel.model({
id: "deepseek-v4-flash",
upstream_model: "deepseek-chat",
name: "DeepSeek V4 Flash",
context_window: 128_000,
}));
federation.use(ai);
```
`stream_openai_compatible_model()` 是边界 Adapter:它把 `ModelCall` 转成 Chat Completions 请求,并把上游 SSE 转成 Downcity 事件。私有协议应实现自己的 Adapter,但保持相同输入输出。
## 进程内 ModelClient
不经过 Federation 时,可以直接创建同协议模型:
```ts
const model = create_openai_compatible_model({
id: "deepseek-chat",
upstream_model: "deepseek-chat",
base_url: "https://api.deepseek.com/v1",
api_key: process.env.DEEPSEEK_API_KEY!,
});
```
## Reasoning 与 fallback
```ts
ai.use(channel.model({
id: "text-model",
upstream_model: "vendor-text-model",
name: "Text Model",
reasoning: {
efforts: [{ id: "high", name: "高" }],
default_effort: "high",
},
fallback: [{
match: (file) => file.media_type.startsWith("image/"),
model_id: "vision-model",
}],
}));
```
AIService 只把最新 user 消息的 `file` 依次交给规则。选出最终模型后再校验 reasoning;最新 user 保持原样,命中最终模型 fallback 规则的历史文件会降级为 filename、原始 URL 或 MIME type 文本,再交给目标 Channel。
Channel 与模型的 `provider_options` 只在服务端合并,由对应 Adapter 解释,不进入公开模型协议。
## 决策模型(System One)
返回带类型判定而非生成文本的模型,实现 `decision()` 而不是 `stream()`:
```ts
import {
AIChannel,
AIService,
type AIChannelActionInput,
type AIChargedResult,
type AIDecisionResult,
} from "@downcity/federation";
class JevChannel extends AIChannel {
constructor() {
super({ id: "typesafe", env: { TYPESAFE_API_KEY: "TypeSafe API Key" } });
}
async decision(input: AIChannelActionInput): Promise> {
const api_key = input.env("TYPESAFE_API_KEY");
if (!api_key) throw new Error("TYPESAFE_API_KEY is required");
return { output: await call_jev(api_key, input.input) };
}
}
const ai = new AIService();
ai.use(new JevChannel().model({
id: "jev-1.13",
upstream_model: "typesafe/jev-1.13",
name: "Jev",
pricing: { currency: "USD", unit: "request", rates: { request: 0.0004 } },
}));
federation.use(ai);
```
Federation 会自动暴露 `POST /v1/ai/decision`,并在请求到达 Channel 前完成校验。目录中该模型的 `model_type` 为 `decision`、`modalities` 为 `["decision"]`;由于它不实现 `stream()`,不会出现在 Agent 的对话模型选择器里。
计量与计费与其它能力走同一条路径:从 Channel 返回 `usage` 即记录用量,声明 `pricing` 即按次计费。
### OpenAI Chat Completions
Source: https://downcity.ai/zh/city-sdk-docs/service/ai/chat-completions/
`POST /v1/ai/chat/completions` 是 Downcity Model Protocol 外层的兼容 Adapter:
```text
OpenAI request
-> ModelCall
-> AIService / AIChannel
-> ModelStreamEvent
-> OpenAI JSON 或 SSE
```
```bash
curl "$FEDERATION_URL/v1/ai/chat/completions" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role":"user","content":"你好"}],
"stream": true
}'
```
Messages、tools、tool choice、图片、reasoning、usage 和 finish reason 都会转换到同一条 Downcity 执行链。该端点不是 raw passthrough。
### 模型中转与 API Key
Source: https://downcity.ai/zh/city-sdk-docs/service/ai/relay/
启用模型中转的 Federation 提供标准 `GET /v1/models` 和 `POST /v1/chat/completions`。OpenAI 客户端的 Base URL 设置为你的 Federation 地址加 `/v1`。流式与非流式请求、工具调用和 usage 都经过同一条 AIService 执行与结算链路。
## 创建和管理 Key
官网用户可以在 [用户中心 → API Keys](/zh/account?view=keys) 管理自己的 Key;完整明文只在创建成功的弹窗中显示一次。SDK 集成使用账户登录得到的 `user_token` 管理自己的 Key。调用 Key 不能访问账户、支付、Key 管理或调用记录查询接口。
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://your-federation.example",
user_token,
});
const issued = await embassy.user.relay.create_key({
name: "编辑器",
allowed_models: ["deepseek-v4-flash"],
spend_limit: 10000,
expires_at: null,
});
// 将 issued.api_key 安全保存到调用客户端;服务端此后无法再次返回明文。
const keys = await embassy.user.relay.list_keys();
await embassy.user.relay.update_key({ key_id: issued.key.key_id, status: "disabled" });
await embassy.user.relay.update_key({ key_id: issued.key.key_id, status: "active" });
await embassy.user.relay.revoke_key(issued.key.key_id);
```
`allowed_models` 填模型或 route 的公开 ID。`[]` 不允许调用任何入口;`["*"]` 允许所有已发布入口。只授权某个 route 不会同时授权其绑定模型。Key 可以重命名、修改权限、额度与到期时间;停用可以恢复,撤销不可恢复。撤销和停用阻断后续新请求,已经开始的调用仍会完成结算。
`spend_limit` 的单位是 Credits,`null` 表示不设 Key 消费上限。调用前检查账户余额和该 Key **已经成功结算**的消费,不预占资金。多个并发在途请求可能使最终消费超过设定值,因此它是软上限。Key 没有独立钱包,实际扣费仍由账户 Credits 账本处理。修改上限不会重置已结算的累计消费;设为 0 会阻断新调用。官网只展示真实上限,后端未返回已用额度时不作推算。
Key 列表展示名称、状态与消费上限;点击名称查看权限、有效期和创建时间,右侧操作支持编辑、停用、恢复和撤销。创建成功后先复制并安全保存,再关闭一次性明文弹窗。不要分享密钥或放入公开代码。
## 在 OpenAI 客户端调用
官网 [模型与接入](/zh/account?view=models) 提供当前可用目录和后端声明价格。点击「接入」查看当前模型能力、完整价格条件,以及可复制的公开模型 ID 和配置推导的 Base URL,填入客户端即可。页面不会读取真实 Key;目录可见不代表你的 Key 已获授权。
价格逐项保留后端币种、计价数量、单位、价格组件与适用条件。多组件或条件价格不能视为统一单价,缺失报价不代表免费,不额外换算 Credits 或推测折扣。上下文或推理档位未声明时保持未知;不能从名称或标签推断工具、视觉等能力。
将 `FEDERATION_URL` 设为服务地址,将 `DOWNCITY_API_KEY` 设为安全保存的调用 Key,并把示例中的模型 ID 替换为目录复制出的模型或 route ID。
```bash
curl "$FEDERATION_URL/v1/models" \
-H "Authorization: Bearer $DOWNCITY_API_KEY"
curl "$FEDERATION_URL/v1/chat/completions" \
-H "Authorization: Bearer $DOWNCITY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"你好"}],"stream":true}'
```
Chat Completions 支持语言模型流式与非流式调用,暂不支持 Responses 和 Anthropic Messages。原生 `POST /v1/ai/stream` 也接受调用 Key,使用 Downcity Model Protocol 的 `model_id` 和 `call`:
```bash
curl "$FEDERATION_URL/v1/ai/stream" \
-H "Authorization: Bearer $DOWNCITY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"protocol_version":1,"model_id":"deepseek-v4-flash","call":{"messages":[{"role":"user","content":[{"type":"text","text":"你好"}]}]}}'
```
原生入口返回 Downcity 模型事件 SSE,不能按 OpenAI SSE 解析,应使用支持原生协议的客户端。启用中转后,语言模型入口保持指定模型的能力和售价;不自动跨模型处理最新媒体。如果模型不支持本轮媒体,显式选择具备相应能力的模型。
## 查询调用与消费
官网 [调用记录](/zh/account?view=requests) 支持本人请求的 Key 筛选、游标分页和安全 attempt 详情。列表展示模型、执行状态、费用和时间;点击模型名称查看 Key、请求 ID、Token、耗时、结算及上游尝试。右侧「筛选」可按 Key 缩小范围,刷新和游标分页沿用原有查询。未知计量与未结算费用保留为未知,不显示为零。
```ts
const page = await embassy.user.relay.list_requests({ limit: 50 });
const record = page.items[0];
if (record) {
const attempts = await embassy.user.relay.list_attempts(record.request_id);
const next_page = await embassy.user.relay.list_requests({ before: record.request_id });
}
```
响应头 `x-request-id` 关联一次逻辑调用,每次上游尝试另有 `attempt_id`。调用记录返回 Key 归属、模型、渠道、Token、耗时和结算状态;尝试记录包含首个内容事件耗时。`charged_credits` 只在结算 `completed` 后显示。失败尝试、缺少 usage 或尚未结算不能解释为零消费。记录不保存提示词、回答正文、调用 Key 或渠道凭据。执行、计量与结算分别记录;进入上游执行前被拒绝的请求可能没有调用记录。首个内容耗时从逻辑请求开始计算,未知结果不会自动重新生成。
## 自建 Federation 启用中转
```ts
const ai = new AIService({
relay: true,
credits: {
precheck: (user_id, needed_credits) => credits.precheck(user_id, needed_credits),
charge: (input) => credits.charge(input),
},
}).use(models);
federation.use(ai);
```
第一次启动会将已注册语言模型导入持久化目录,保留原有适配器和计费函数。后续新增模板需通过管理 SDK 发布到目录。公开模型的能力、reasoning 与售价取自已注册模板,管理员可以配置启停、渠道、上游映射、优先级、权重与 route。官网提供用户自己的 Key 管理页。管理员可在 Fedman 的「渠道与路由」管理公开模型、渠道、部署和 route,或使用下列管理员 SDK。
```ts
const admin = new Embassy({ federation_url, admin_token });
const metadata = await admin.admin.relay.metadata(); // 已注册模板与适配器,不含凭据
const health = await admin.admin.relay.health(); // 共享部署冷却快照,不是主动探测
const snapshot = await admin.admin.relay.catalog();
// 按需要修改 snapshot.catalog,再用读取时的 revision 提交。
await admin.admin.relay.configure(snapshot.catalog, snapshot.revision);
```
Fedman 表单从实际目录与 `metadata()` 生成可选项。支持创建、编辑、启停和确认删除;有依赖引用时删除被拒绝,不隐式级联。每次保存提交读取时的 revision,409 冲突需刷新最新目录后重新编辑,不自动覆盖。registered 渠道必须已安装,runtime 与上游映射固定;OpenAI 渠道只填写服务端凭据环境变量名称,不在表单填写密钥。部署列表展示共享冷却截止时间;无冷却记录不代表上游健康,不提供主动付费探测。
目录将 Model、Channel、Deployment 与 Route 分开管理:Model 绑定能力和售价模板;Channel 选择 `registered` 或 `openai_chat_completions` 适配器;Deployment 关联公开模型、渠道和真实上游模型;Route 选择同一公开模型的部署集合。标准 OpenAI 渠道的 `base_url` 使用不含凭据的 HTTPS API 根地址,`credential_env` 引用 Federation 的环境变量;目录接口不会返回凭据明文。新增渠道必须确实支持模型模板声明的能力。
默认策略 `priority_weighted` 优先选择较高 `priority`,同层按正整数 `weight` 抽样。直接模型入口最多尝试两次;route 的 `max_attempts` 可设为 1–3。明确的 401、403、429 拒绝允许切换其他候选;网络异常、5xx、超时或开始读取上游流后不自动重试。健康冷却保存在共享数据库,429 尊重 `Retry-After`,最长五分钟,其他故障短暂冷却,到期后恢复正常选取。渠道 `timeout_ms` 默认为 120000,范围 1000–300000,适配器需响应取消信号。
目录在实例中缓存五秒,修改通过 `revision` 防止覆盖并发更新,其他实例最多经过一个缓存周期看到新配置。路由不额外调用模型;Key 状态每次读取,有限额 Key 的已结算消费检查与账户余额检查并行。调用开始前仍需持久化请求和尝试日志,并读取共享健康状态,真实增加的延迟取决于数据库部署和上游,需在实际环境测量。
当前不提供 RPM、TPM、全局并发租约、Responses、Anthropic Messages 或 OAuth 账号池。
### 调用模型
Source: https://downcity.ai/zh/city-sdk-docs/service/ai/calling/
## 直接调用 Federation 模型
```ts
const catalog = await city.ai.catalog();
const model = catalog.require("deepseek-v4-flash");
const stream = await model.stream({
messages: [{
role: "user",
content: [{ type: "text", text: "解释这个仓库" }],
}],
});
for await (const event of stream) {
if (event.type === "text_delta") process.stdout.write(event.delta);
}
```
`model.stream()` 返回 `ReadableStream`。成功流以 `model_start` 开始,在 `model_finish` 前提供最终 `model_usage`;协议顺序无效时客户端会直接报错。
## 作为 Agent 模型
模型目录返回的对象实现 `ModelClient`,可以直接交给 Agent:
```ts
const catalog = await city.ai.catalog();
const model = catalog.require("deepseek-v4-flash");
const agent = new Agent({ id: "reviewer", model });
```
Agent 负责 Session 上下文、工具循环和审批;Federation 负责最终模型选择与计费。
## 多模态 fallback
Fallback 只检查 `ModelCall.messages` 中最新一条 `role: "user"` 消息的 `file` 内容。最新用户消息只有文本时不会再次触发 fallback;如果最终模型的 fallback 规则不接受某个历史文件,该文件会在本次执行视图中降级为 `filename`、原始 URL 或 MIME type 文本。Session 保存的原始文件不变。末尾即使存在 assistant 或 tool 消息,也会向前定位最近的 user。
## OpenAI-compatible 客户端
需要使用 OpenAI SDK、curl 或现有网关时,调用 `/v1/ai/chat/completions`。该端点会在边界把 OpenAI 请求转换为同一个 `ModelCall`,路由、fallback、reasoning、usage 和计费语义保持一致。
## 调用决策模型
决策模型接收原始状态与带类型的问题,返回带校准概率的判定,不生成文本:
```ts
/** 通过当前 Federation 用户身份调用 Jev。 */
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "http://localhost:3000",
user_token: process.env.FEDERATION_USER_TOKEN,
});
const { answers } = await embassy.user.ai.decision({
model: "jev-latest",
state: { type: "text", text: "客户连续三天未能连接 Stripe。" },
questions: [
{ id: "urgent", type: "boolean", instructions: "这条消息是否表达紧急?" },
{ id: "route", type: "choice", instructions: "应该由哪个队列处理?", options: ["billing", "support"] },
{ id: "churn_risk", type: "score", instructions: "流失风险", min: 1, max: 4 },
],
});
answers[0].value; // true
answers[0].probability; // 0.87
answers[1].distribution; // { billing: 0.72, support: 0.28 }
```
官方 edge-worker 和 local-node 均提供 `jev-latest`,通过 B.AI 官方的 `https://api.b.ai/v1/decisions` 接口执行结构化决策。管理员在 Federation 的 Global / Env 配置 `B_API_KEY` 后,即可使用 `choice`、`score` 和 `boolean` 判定,也可在 Desktop 的 Group 调度模型中选择 Jev。
官方 edge-worker 使用 Downcity SDK 1.5.0,模型目录通过结构化 `pricing` 返回货币、计费单位、单价及适用条件。
Jev 的 `choice` 支持 2–255 个候选;`score` 的区间必须对应 2–10 个层级(`round(max - min) + 1`);`boolean` 按 0.5 概率分界返回结果。JSON 状态不能是空对象或空数组。
`state` 为 `{ type: "text", text }` 或 `{ type: "json", data }`。支持三种问题类型,都不产生自由文本:
| 类型 | 答案 | 说明 |
| --- | --- | --- |
| `boolean` | `true` / `false` | — |
| `choice` | `options` 中的一个 | 1 到 255 个不重复候选取值 |
| `score` | 数值 | 需满足 `min < max` |
问题 ID 必须唯一,且每个问题必须且只能被回答一次。非法输入在任何用量记录产生前返回 422;Channel 返回未覆盖全部问题的结果同样会被拒绝。
`embassy.user.ai.decision(input, signal)` 支持可选的 `AbortSignal`,答案通过 `question_id` 对应问题。Group 可以绑定该方法作为决策客户端,每轮选择并行回复成员;成员保留自己的执行模型。
决策模型不是对话模型:它的 `modalities` 只有 `decision`,不能交给 Agent。
### 上线前检查
Source: https://downcity.ai/zh/city-sdk-docs/operations/production-checklist/
上线前至少确认这些事项:
## 安全
- 管理员 ID、密码和会话 Token 只存在可信环境。
- client 只拿 `user_token`,不接触 provider key。
- `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 已配置到 City。
- 管理端 env 接口只允许可信请求。
## 稳定性
- 每个要暴露的 service 都已经通过 `base.use(service)` 注册。
- AI model 已通过 `AIChannel.model()` + `AIService.use()` 注册,并包含需要的能力。
- 关键 provider env 已配置。
- city 表使用明确的数据库 URL。
- before hook 会阻止超额用户。
- after hook 会记录 usage 和错误线索。
## 多产品
- `bureau_id` 能区分不同产品的 Bureau 身份。
- usage 记录包含 bureau_id、user_id、model。
- 不同产品的限额策略有默认值。
## 回滚
- provider key 可以快速轮换。
- 模型定义可以通过代码发布快速暂停或替换。
- service 错误会被 `onError` 或日志系统记录。
### 部署到 Cloudflare Workers
Source: https://downcity.ai/zh/city-sdk-docs/operations/deploy-cloudflare-workers/
这页是 Federation 项目部署到 Cloudflare Workers 的正式操作路径。
如果你只是想理解 Federation 怎么接入 Workers,先读 [Cloudflare Workers](/zh/city-sdk-docs/guides/cloudflare-workers)。这一页只讲上线步骤:`federation.json`、本地创建、本地部署、D1 / Queue / R2、交互式管理员配置、env、OAuth callback 和健康检查。
## PostgreSQL 与 Supabase
Worker 可以保留 Queues 和 R2,并通过 Hyperdrive 连接托管 PostgreSQL。数据库适配器使用 `@downcity/database-postgresql`;现有 Accounts、Credits、Payment 和 AI 服务继续由 Federation 提供,不需要切换到 Supabase Auth 或 Storage。
PostgreSQL 客户端应限制在一次 Worker invocation 内创建和释放,不能跨请求复用 socket。流式响应结束或取消后,等待该请求登记的后台任务结束再释放连接。上游连接池由 Hyperdrive 承担;余额、鉴权和账务查询禁用查询缓存。Supabase 连接凭证只存放在密钥管理或本机环境变量中。
从 D1 迁移时必须保留全部源表,包括历史表。先暂停所有写入、队列消费者和定时处理并完成一致性导出;在空 PostgreSQL 调用 `prepare_schema()` 只建表,导入并校验原始身份、密钥、账户、余额和账本后,再调用 `prepare_database()` 建立约束并验证运行。不能先生成新身份再覆盖旧数据,也不能在生产入口指向空数据库时上线。
迁移工具的源数据保全层和按表内容摘要用于核验完整性;历史表归档不等于业务表已可供应用使用。切换后的新写入留在 PostgreSQL,回滚前需要回迁及对账。
## 前置条件
- 一个 Cloudflare 账号
- 已经在项目里安装依赖
Downcity 不会在 `git push` 后自动部署 Worker。部署是显式动作:
```bash
fed deploy
```
## 1. 创建 Federation 项目
开发者通常从一个空目录开始:
```bash
mkdir my-city
cd my-city
fed create . --template cloudflare-workers
```
`fed create . --template cloudflare-workers` 会询问 Federation 名称并生成 Worker 项目骨架:
- `federation.json`
- `src/index.ts`
- `package.json`
- `tsconfig.json`
生成后的 `federation.json` 只保存项目基本信息:
```json
{
"schema": 1,
"type": "federation",
"id": "fed_example",
"name": "my-city",
"entry": "src/index.ts",
"deployment": {
"target": "cloudflare-workers",
"resources": {
"d1": {
"type": "d1",
"binding": "DB",
"name": "my-city-db"
},
"queue": {
"type": "queue",
"binding": "DOWNCITY_QUEUE",
"name": "my-city-queue"
},
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "my-city-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
`id` 是不依赖路径的 Fed 身份,`deployment.target` 表示部署目标。`entry` 和 `deployment.resources` 是可提交的部署意图;D1 database id、Cloudflare account、Worker URL 和 Wrangler 配置由 `fed deploy` 处理。
如果项目模板已经在 Git 仓库里,先用 `fed create` 拉到本地:
```bash
fed create my-city --template https://github.com/example/my-city.git
cd my-city
```
`fed deploy` 不直接部署远程 URL。它只部署本地 Federation 项目,这样 `.env` 和部署结果有明确归属。
## 2. 部署当前目录
在 Federation 项目目录里直接执行:
```bash
fed deploy
```
如果 `federation.json` 的 `deployment.target` 是 `cloudflare-workers`,`fed deploy` 会自动检查 Cloudflare 登录态。没有登录时会唤起 Wrangler 登录;account id 保存在系统级 Federation registry,后续部署自动复用。
也可以显式传目录:
```bash
fed deploy ./my-city
```
## 3. 资源配置
Cloudflare Workers 目标当前支持三类资源:
```json
{
"deployment": {
"target": "cloudflare-workers",
"resources": {
"d1": {
"type": "d1",
"binding": "DB",
"name": "my-city-db"
},
"queue": {
"type": "queue",
"binding": "DOWNCITY_QUEUE",
"name": "my-city-queue"
},
"storage": {
"type": "r2",
"binding": "DOWNCITY_STORAGE",
"name": "my-city-storage",
"public_url_prefix": "https://images.example.com"
}
}
}
}
```
如果没有显式配置 D1 / Queue,`fed deploy` 会根据 `federation.json.name` 使用默认名称 `${name}-db` 和 `${name}-queue`。Storage 只有在 `deployment.resources.storage` 存在时启用。
部署时会复用同名 D1、Queue 和 R2 bucket;没有同名资源且不是 `--dry-run` 时会自动创建。D1 database id 是 Cloudflare 部署状态,不会写回项目配置。
Cloudflare account 不属于 Federation 项目配置。`fed deploy` 会优先复用 Wrangler 登录态;只有 Cloudflare 无法自动识别 account 时,才会引导你输入一次并保存到本地 fed CLI 状态。
也可以部署时临时传入 account id:
```bash
fed deploy --account-id
```
## 4. Dry-run
正式发布前先检查 bundle 和 Wrangler 配置:
```bash
fed deploy --dry-run
```
如果部署别的目录:
```bash
fed deploy ./my-city --dry-run
```
## 5. 部署过程
`fed deploy` 会执行这些步骤:
- 读取目标目录的 `federation.json`
- 根据 `federation.json.deployment.target` 检查目标平台登录态;Cloudflare Workers 会自动唤起 Wrangler 登录或引导选择 account
- 如果不是 `--dry-run`,先把项目 `package.json.version` 自动做一次 patch 自增
- 如果 package.json 有 `build`,执行 `pnpm build`
- 如果 package.json 有 `typecheck`,执行 `pnpm typecheck`
- 对 `cloudflare-workers` target 按 `deployment.resources.d1.name` 查找 D1;不存在时自动创建
- 按 `deployment.resources.queue.name` 查找 Queue;不存在时自动创建
- 如果声明了 `deployment.resources.storage`,按对应名称查找 R2 bucket;不存在时自动创建
- 临时生成 Wrangler 配置,不写入项目目录
- 构建并加载 Worker 的 `prepare_database(env)` 导出,通过项目 Wrangler 的 `getPlatformProxy` 将 `env.DB` 连接到本次部署的远程 D1,执行 schema、bootstrap 和服务准备;失败则停止发布
- 首次初始化或使用 `--admin-reset` 时交互式要求输入管理员凭证,通过部署权限把密码摘要写入 D1
- 执行 `wrangler deploy --config `
- 请求 Worker `/health` 检查运行时就绪,并在应用新凭证后请求 `/v1/admin/login` 验证
- 把 Worker URL、部署状态、管理员 ID 和有期限的 Session Token 登记到本地 Federation registry,但不修改当前 active Federation
- 在传入 `--verify` 时请求 Worker `/health`
部署输出会按阶段汇总显示:`Project`、`Version`、`Build`、`Typecheck`、`D1 Database`、`Queue`、`Storage`、`Wrangler`、`Deployment` 和 `Federation Registry`。
downfed 没有默认 Federation。部署完成后,新实例会出现在 downfed 列表中,但只有用户明确打开它时才会写入 `active_server_url`。
`federation.json` 只保留可提交的项目声明。业务密钥、provider keys 和 service env 应该写入 Federation 自己的 env 表,不写入 `federation.json`。
`fed deploy --dry-run` 和 `fed deploy --verify-only` 不会修改项目版本号。
### Worker 数据库准备入口
Worker 必须提供具名导出;部署准备与请求共用服务装配函数,但使用不同实例:
```ts
function create_federation(env: Env): Federation {
const fed = new Federation({
database: new Database({ binding: env.DB }),
database_initialization: "prepared",
});
// 在这里注册与请求实例相同的服务。
return fed;
}
export async function prepare_database(env: Env): Promise {
const fed = create_federation(env);
await fed.prepare_database();
}
```
CLI 使用项目安装的 Wrangler、其 esbuild 和项目依赖在 Node 子进程中加载入口,项目需安装支持远程 D1 binding 的 Wrangler。入口及其导入模块必须支持 Node 加载;不要在模块顶层访问仅 Worker 提供的全局对象或执行数据库操作。临时构建目录位于项目内,完成或失败后删除;代理始终释放。本地 `.env` 不会传入准备代理,数据库 binding 使用临时配置的精确 ID 和 `remote = true`,Queue/R2 仍为本地代理;准备阶段只应执行数据库工作。
`--dry-run` 构建、加载并检查导出,但不调用准备函数、不建立远程 D1 代理;`--verify-only` 不进入准备阶段。`--skip-build` 不跳过数据库准备。覆盖 `deployment.scripts.deploy` 的项目自行负责数据库准备。旧项目缺少导出时部署会失败,需要更新 Worker 及实际安装的 SDK 包。`maintenance()` 应由周期任务调用,不在每次冷启动执行。
准备和管理员写入先于上传,上传失败不会回滚这些数据库修改;`--admin-reset` 可能已经使旧管理员会话失效。历史 SQL 迁移不会被自动扫描或执行。
## 6. 开发者 edge 快捷示例
Downcity 仓库里保留了 `templates/edgefed` 作为开发者快捷示例:
```bash
fed deploy templates/edgefed --verify
```
这个示例适合本地实验和自定义部署。Downcity 官方私有生产 Worker 实现维护在公开仓库之外。
## 7. 初始化 Federation
部署完成后,请求一次 `/health`。这会让 Worker 启动 Federation,并创建内置的 `env` / `cities` 表。
```bash
curl https://my-city..workers.dev/health
```
返回大致如下:
```json
{
"ok": true,
"services": ["env", "cities", "bureaus"]
}
```
## 8. 管理员登录
首次 `fed deploy` 时,Downcity 会交互式要求输入管理员 ID、密码和确认密码。密码至少 12 个字符,D1 只保存 PBKDF2 摘要。遗失凭证时运行 `fed deploy --admin-reset`;该操作需要部署权限,会撤销全部旧管理员 Session,并要求输入新凭证。非交互环境中的 `--yes` 只确认破坏性重置,不会跳过凭证输入。
部署后,`fed web`、管理员工作区和 `fed query` 都通过 `/v1/admin/login` 登录,并使用有期限的管理员 Session Token。远端 Session Token 不会下发到浏览器,只保留在本地 CLI BFF 或 registry 中。
## 9. 写入 provider 和 service env
Provider API key 和服务密钥应该写入 Federation 自己的 env 表,不应该进入公开客户端。
示例:
```bash
curl -X POST https://my-city..workers.dev/v1/env/upsert \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"key":"DEEPSEEK_API_KEY","value":"sk-..."}'
```
常见 key:
| Key | 用途 |
| --- | --- |
| `DEEPSEEK_API_KEY` | edge 快捷示例默认使用的 provider key |
| `STRIPE_SECRET_KEY` | 创建 Stripe Checkout 的 API key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook 签名密钥 |
| `DOWNCITY_CITY_BASE_URL` | City 对外访问地址,用于生成支付结果页 |
| `CREEM_API_KEY` | 创建 Creem Checkout 的 API key |
| `CREEM_PRODUCT_ID` | Creem 托管 Checkout 使用的一次性、含税支付 product |
| `CREEM_CURRENCY` | Creem product 的币种,必须与产品实际币种一致 |
| `CREEM_WEBHOOK_SECRET` | Creem webhook 签名密钥;缺失时 Provider 不可用 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选,Google OAuth 登录 |
| `WECHAT_CLIENT_ID` / `WECHAT_CLIENT_SECRET` | 可选,微信网站应用登录 |
## 10. 配置 OAuth callback
如果你开启 OAuth 登录,需要把 provider 的 callback URL 配成 Worker 对外地址:
```text
https://my-city..workers.dev/v1/accounts/oauth/callback
```
callback 域名必须和用户实际访问的公开域名一致。如果之后切到自定义域名,也要同步更新 OAuth provider 里的回调地址。
## 11. 验证
如果当前已经连接过 Federation,可以只执行健康检查:
```bash
fed deploy --verify-only
```
也可以手动检查:
```bash
curl https://my-city..workers.dev/
curl https://my-city..workers.dev/health
```
## 更新部署
修改代码后:
```bash
fed deploy --verify
```
运行时 env 保存在 D1 里,所以重新部署 Worker 不会丢失 provider key、OAuth secret、city、余额、usage 记录或 Stripe 支付记录。
### Dokploy 部署
Source: https://downcity.ai/zh/city-sdk-docs/operations/deploy-dokploy/
`templates/localfed` 默认用于本机开发。通过 Compose 部署时会关闭 Local Account,避免无需凭证的本地登录入口暴露到公网,并把 SQLite 数据保存在持久化 `/data` volume 中。
## Dokploy 配置
在 Dokploy 里创建应用,选择当前 Git 仓库:
| 配置项 | 值 |
| --- | --- |
| 部署模式 | Compose |
| Compose 文件 | `./docker-compose.yml` |
| Container port | `43127` |
| Volume | Compose 自动创建 `downcity_infra_data` |
`HOST`、`PORT`、数据库路径和登录模式已经写在 `docker-compose.yml` 中:
```txt
HOST=0.0.0.0
PORT=43127
DATABASE_PATH=/data/downcity.sqlite
LOCAL_LOGIN=false
```
`/data` 必须是持久化 volume。City、账户和 Federation env 都会保存在这个 SQLite 数据库里。
## 域名路径
Node 街区默认监听 `43127`,Federation API 直接挂在服务根路径下:
```txt
GET /health
POST /v1/env/upsert
GET /v1/services
POST /v1/ai/stream
```
如果你的域名是 `https://base.example.com`,产品侧 SDK 和可信后端应该使用:
```txt
base_url=https://base.example.com
```
## Provider Key
`localfed` 模板直接读取进程环境中的 `DEEPSEEK_API_KEY`。在 Dokploy 中把它配置为 Secret:
```txt
DEEPSEEK_API_KEY=sk-...
```
## 验证
Dokploy 部署完成后,先验证公开服务:
```bash
curl https://base.example.com/health
```
只用 IP 和端口时:
```bash
curl http://1.2.3.4:43127/health
```
预期返回大致如下:
```json
{
"ok": true,
"services": ["env", "cities", "bureaus", "accounts", "ai"]
}
```
如果这里超时,说明 Dokploy 服务没有对外暴露、VPS 防火墙没放行 `43127`,或还没有绑定可访问的域名。
## 产品侧调用
产品前端和应用通过 Embassy 调用 Federation:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://base.example.com",
user_token: "user_token",
});
```
可信后端使用 Embassy Admin 子域携带管理员会话 Token:
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "https://base.example.com",
admin_token: administrator_session_token,
});
```
完整 SDK 能力继续看 [Embassy User](/zh/city-sdk-docs/reference/city-user) 和 [Embassy Admin](/zh/city-sdk-docs/reference/city-admin)。
### env 与 key 轮换
Source: https://downcity.ai/zh/city-sdk-docs/operations/env-rotation/
Federation 集中管理 key 的好处是:多个产品不需要分别更新配置。
## provider key
provider key 可以通过 Runtime env 管理,并保存到 Federation 数据库:
```ts
await admin.env.upsert({
key: "OPENAI_API_KEY",
value: "sk_new",
});
```
轮换后,所有接入该 Federation 的 service handler 都会读取到新值。数据库中的 env 值优先于 `.env`。
## Downcity 自己的 key
Downcity 自己维护两类关键密钥:
- `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY`
### admin secret
`Embassy Admin` 使用通过 `/v1/admin/login` 获取的有时限管理员会话 Token 管理 Federation。
### token signing key
`DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 用于 Federation 内部签发和校验 `user_token`。
## 轮换时要注意什么
- 轮换 provider key 一般不影响已签发的 `user_token`
- 轮换 `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 会让旧 token 失效
- 如果你在线上轮换 signing key,通常需要提前缩短 token TTL,或安排明确切换窗口
## 建议
- 给 provider key 设置内部 owner
- 记录 key 轮换时间
- 把生产 key 轮换放到可信后端或运维流程中,不依赖浏览器手工操作
### 日志与 usage
Source: https://downcity.ai/zh/city-sdk-docs/operations/logging-and-usage/
Downcity 不决定日志存哪里,但 hook 会给你统一入口。
## 推荐字段
- user_id
- bureau_id
- model
- started_at
- ended_at
- status
- error
## 记录位置
```ts
ai.hook.after(async (ctx) => {
await logService.write({
user_id: ctx.user?.user_id,
bureau_id: ctx.bureau?.bureau_id,
model: ctx.variant?.id,
model_meta: ctx.variant?.meta,
started_at: ctx.started_at,
ended_at: ctx.ended_at,
});
});
```
多产品共用同一套 runtime 后,usage 数据天然集中,后续做成本分析和商业决策会更简单。
### 常见问题
Source: https://downcity.ai/zh/city-sdk-docs/operations/troubleshooting/
## client 返回 401
检查:
- 请求是否带了 `Authorization: Bearer `。
- token 是否过期。
- City 是否配置了 `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY`。
- token 是否由当前 Federation 签发。
- token 中的 `bureau_id` 是否对应当前产品。
## client 返回 403
检查:
- token 所属 Bureau 是否已经被暂停。
- 管理端签发 token 时,目标 Bureau 是否是 `active`。
## client 返回 404
检查:
- 调用的 HTTP 路由是否正确。
- 目标 service 是否已经在 City 里注册。
- 管理端签发 token 时,目标 Bureau 是否存在。
## 模型不存在或没有命中 handler
检查:
- client 传入的 `model` 是否已通过 `AIChannel.model()` + `AIService.use()` 注册
- 目标 model 是否包含当前通路的 action,例如 `text`、`stream` 或 `image`。
- 请求里是否传入了预期的 model id。
- `city.ai.catalog()` 是否能看到这个 model。
这类问题通常会返回 `422`。
## City 返回 500
检查:
- City 是否配置了 `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 并已通过 `fed deploy` 配置管理员账户。
- 目标 service 是否已经注册 handler。
- handler 或 provider 调用是否抛出了没有 `statusCode` 的错误。
- `onError` hook 是否记录了原始错误。
## provider 调用失败
检查:
- City `.env` 是否存在对应 key。
- handler 是否通过 `input.env()` 读取了正确的 key。
- model `meta` 是否和当前 provider 调用方式匹配。
## 文件工具长时间没有完成
“生成文件内容中”表示模型正在准备文件内容,尚未执行写入;卡片中的已接收字符数用于观察生成进展。“等待执行”和“写入文件中”分别表示参数准备完成和实际开始写入。
如果提示达到输出上限或响应缺少正常结束标记,本次工具不会执行。请缩小单次修改范围,按章节编辑长文,并把大型 SVG 插图保存为独立文件。再次中断时,先检查模型服务状态,避免反复提交同一个超长请求。
如果 `edit` 提示参数类型错误,应修正原编辑调用:`edits` 必须是数组,不能是编码后的 JSON 字符串。局部修改不需要重写全文。
## usage 没有记录
检查:
- `AIService` 和 `CreditsService` 是否先于 `UsageService` 挂载,并作为对应 Reader 传入。
- AI Provider 是否返回了最终 `RuntimeMetering`;没有最终计量时只增加执行次数。
- Credits 交易是否为已入账的正数 Charge;充值、失败或待处理交易不会计入消费。
- 查询是否传入真实、有序的 `from`、`to` 和有效 IANA `timezone`。
### Federation Client SDK
Source: https://downcity.ai/zh/city-sdk-docs/reference/client-sdk/
客户端 API 对应三个明确身份:
| API | 凭证 | 用途 |
| --- | --- | --- |
| `embassy.user` | User Token | 登录、AI、Payment 与用户 Service |
| `embassy.admin` | Admin Session Token | Federation 管理 |
| `Bureau` | Bureau Token | 可信产品后端与本地 User Token 验签 |
```ts
import { Bureau, Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url });
const bureau = new Bureau({ federation_url, bureau_token });
```
不要把 User Token、Admin Session Token 和 Bureau Token 放进同一个共享客户端对象。它们的
所有者、生命周期和信任边界不同。
### Runtime API
Source: https://downcity.ai/zh/city-sdk-docs/reference/runtime-api/
这一页保留为轻量概览。新的阅读顺序是:
- [参考首页](/zh/city-sdk-docs/reference):先按角色选入口
- [Federation](/zh/city-sdk-docs/reference/federation)
- [HTTP API](/zh/city-sdk-docs/reference/http-api)
- [环境变量](/zh/city-sdk-docs/reference/env-vars)
## Federation 负责什么
- 初始化 Runtime 数据库
- 校验 `user_token`
- 提供统一的 `/v1/*` HTTP 路由
- 通过 `base.use(service)` 注册 Service、AIService 和服务
- 把 Service action 和服务路由挂到同一个 `/v1/*` 空间
- 通过 hook 接入 usage、套餐、余额、扣费、日志和风控
具体方法请继续查看 [Federation](/zh/city-sdk-docs/reference/federation)。
### Federation
Source: https://downcity.ai/zh/city-sdk-docs/reference/federation/
`Federation` 是 Downcity 服务端的核心实例(即文档里说的 Federation)。它封装了 HTTP 路由、鉴权、环境变量和数据库基础设施。客户端访问入口是 [`City`](/zh/city-sdk-docs/reference/city-user),不要和它混淆。
## 最小使用
```ts
import { Federation } from "@downcity/federation";
const base = new Federation({ database });
```
## Service 与 Action
每个 Service 是一组 Action。通过 `base.use(service)` 注册:
```ts
import { Service } from "@downcity/federation";
const translate = new Service({ id: "translate", name: "翻译" });
// 注册 Action
translate.action("zh2en", async (ctx) => {
return { result: await api.translate(ctx.input.text, "en") };
});
translate.action("en2zh", async (ctx) => {
return { result: await api.translate(ctx.input.text, "zh") };
});
base.use(translate);
```
Federation 自动为每个 Action 生成 HTTP 路由:
```
POST /v1/translate/zh2en → translate.action("zh2en")
POST /v1/translate/en2zh → translate.action("en2zh")
```
## AI Service
AIService 是内置的 Service,用于管理 AI 模型:
```ts
import {
AIService,
AIChannel,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
base_url: "https://api.deepseek.com/v1",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const deepseek = new DeepSeekChannel();
const ai = new AIService();
ai.use(deepseek.model({ id: "deepseek-v4-flash", upstream_model: "deepseek-chat", name: "DeepSeek V4 Flash" }));
base.use(ai);
```
## Action 注册选项
```ts
// 默认 POST,auth=["user"]
svc.action("zh2en", fn);
// GET action
svc.action("list", fn, { method: "GET" });
// Admin action
svc.action("remove", fn, { auth: ["admin"] });
// Guest action(免登录)
svc.action("webhook", fn, { auth: [] });
```
## Hook 三层
```ts
// Action 级 hook(只在这个 action 触发)
const zh2en = svc.action("zh2en", fn);
zh2en.before(checkBalance).after(deductFee);
// Service 级 hook(所有 action 共享)
svc.hook.before(logRequest);
// 执行顺序:
// action.before → service.before → action.run → service.after → action.after
```
## 路由启动
```ts
import { serve } from "@hono/node-server";
serve({ fetch: (request) => base.fetch(request), port: 43127, hostname: "127.0.0.1" });
```
## HTTP Middleware
`base.middle()` 用于注册 Federation 级 HTTP middleware。它们会在内部路由和 Action body 读取之前执行,因此调用方可以在 Federation 入口接入 CORS、安全响应头、body 大小限制、粗粒度限流、请求超时或其他 HTTP 策略。
`@downcity/agent` 只定义 middleware 接口,不实现也不导出 CORS、限流、超时、安全响应头、body 限制、客户端 IP 读取等 helper。这些策略应由你的应用、平台或 HTTP 框架提供。
```ts
base.middle(async (ctx, next) => {
const started_at = Date.now();
const response = await next();
const headers = new Headers(response.headers);
headers.set("X-Downcity-Time", String(Date.now() - started_at));
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}));
```
middleware 按注册顺序进入,按相反顺序退出。middleware 可以直接返回 `Response` 来短路请求,也可以 `await next()` 后修改响应头。
## instruction 文档聚合
`Federation` 可以直接返回当前实例的说明文档字符串:
```ts
const text = await base.instruction();
```
这份文本会聚合:
- Federation 自身怎么使用
- 当前注册的 service
- 每个模块声明的 env 配置
- 服务补充的使用说明
如果你要从远程管理端读取同一份说明,可以调用:
```txt
GET /v1/base/instruction
```
这个接口只允许管理端访问,返回 `text/plain`。
## 鉴权
Federation 自动处理 `user_token` 和管理员会话 Token 的校验。Action 默认要求 `user_token`;传 `auth: ["admin"]` 时只允许管理端访问,传 `auth: []` 时表示免登录。
详见 [信任边界](/zh/city-sdk-docs/concepts/trust-boundary)。
### Embassy User
Source: https://downcity.ai/zh/city-sdk-docs/reference/city-user/
只用 Federation URL 创建 Embassy,再使用它的 `user` 身份子域。
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
const providers = await embassy.user.account.providers();
```
## Account
```ts
await embassy.user.account.login({
provider: "email",
client_type: "cli",
bureau_id: "product-web",
input: { email: "user@example.com", password: "password" },
});
const current_user = await embassy.user.current();
const user_token = embassy.user.account.token();
await embassy.user.account.logout();
```
`bureau_id` 属于登录请求,不属于 Embassy 构造参数。登录成功会更新当前实例持有的 User
Token。创建 Embassy 时也可以直接传入已有 `user_token`。邮箱登录须先完成邮箱验证。`login()` 自动交换已完成流程的 Token,单独调用 `status()` 不交付 Token。退出先撤销当前 Token 再清理本地凭证,失败保留凭证以便重试。新的 Bureau 受保护请求依赖 Federation 在线验证,检查不可用时拒绝访问;已开始的流式请求继续运行。
## Service
```ts
const models = await embassy.user.ai.catalog();
const methods = await embassy.user.payment.methods();
const services = await embassy.user.list_services();
const result = await embassy.user.service("rewrite")
.action("formal")
.invoke({ text: "hello" });
```
`embassy.user.get()` 和 `post()` 只请求 Federation 根据当前 User Token 解析出的可信 Bureau
server。Embassy 会拒绝把 Token 转发到其他 origin。
### Embassy Admin
Source: https://downcity.ai/zh/city-sdk-docs/reference/city-admin/
管理员访问是 Embassy 的 `admin` 身份子域。
```ts
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({ federation_url: "https://fed.example.com" });
await embassy.admin.login({ admin_id: "owner", password: "password" });
```
Federation 返回有时限的 Admin Session Token。Embassy 在当前实例中持有它;`token()`
读取 Token,`logout()` 撤销它。
## Bureau 管理
```ts
const bureaus = await embassy.admin.bureaus.list();
const bureau = await embassy.admin.bureaus.create({
bureau_id: "product-web",
name: "Product Web",
server_url: "https://product.example.com",
});
await embassy.admin.bureaus.route.update({
bureau_id: bureau.bureau_id,
server_url: "https://new-product.example.com",
});
```
## Bureau Token
```ts
const issued = await embassy.admin.bureaus.tokens.issue({
bureau_id: "product-web",
purpose: "production backend",
});
const tokens = await embassy.admin.bureaus.tokens.list("product-web");
await embassy.admin.bureaus.tokens.revoke(issued.token_id);
```
Federation 只在签发时返回 Bureau Token 明文。可信后端保存它并使用独立 `Bureau` 客户端
执行 `identify()`;Embassy Admin 不负责识别终端用户请求。
### HTTP API
Source: https://downcity.ai/zh/city-sdk-docs/reference/http-api/
如果你已经在用 SDK,通常不需要手写 HTTP 请求。
## 路由规则
所有 Action 自动映射为 HTTP 路由:
```
动作 URL
──── ───
普通 action POST /v1/{service}/{action}
GET action GET /v1/{service}/{action}
Admin action POST /v1/{service}/{action}
Admin GET GET /v1/{service}/{action}
```
### 产品侧路由
| 路由 | 方法 | 用途 |
| --- | --- | --- |
| `/v1/ai/stream` | `POST` | Downcity Model Protocol 模型流 |
| `/v1/ai/image/create` | `POST` | 创建图片生成任务 |
| `/v1/ai/image/result` | `POST` | 查询图片生成任务 |
| `/v1/ai/video` | `POST` | 视频生成 |
| `/v1/ai/decision` | `POST` | 结构化决策(System One) |
| `/v1/ai/chat/completions` | `POST` | OpenAI 兼容端点 |
| `/v1/ai/models` | `GET` | 按身份返回模型目录 |
| `/v1/accounts/login/start` | `POST` | 指定 `client_type` 创建流程,返回 `login_id`、`login_secret` 和下一步 |
| `/v1/accounts/login/continue` | `POST` | 携带 `login_id`、`login_secret` 提交 input 登录步骤 |
| `/v1/accounts/login/result` | `POST` | 携带领取凭据查询状态,不返回 Token |
| `/v1/accounts/login/exchange` | `POST` | 携带领取凭据和稳定 `exchange_id` 交换 `user_token` |
| `/v1/accounts/logout` | `POST` | 撤销当前 Bearer User Token |
| `/v1/federation/token` | `GET` | 在线验证当前 Bearer User Token |
| `/v1/accounts/me` | `GET` | 读取当前用户及其已验证的 Bureau |
| `/v1/accounts/oauth/callback` | `GET` | 第三方 OAuth 回调入口 |
| `/v1/{service}/{action}` | `POST` | 通用 Action 调用 |
| `/v1/services` | `GET` | Service 列表 |
`/v1/accounts/oauth/callback` 是给 GitHub / Google 这类第三方 OAuth 平台回跳用的固定地址。产品前端通常不直接调用它。
`login_secret` 仅由发起端保管,通过 JSON 请求体提交,不放入 URL、页面或日志。`client_type: "web"` 要求同源请求与发起浏览器 Cookie;`cli`、`desktop`、`device` 的 OAuth 流程返回 `user_code`,用户从原始客户端读取后输入授权确认页。状态完成后才交换 Token;同一次交换的网络重试复用原 `exchange_id`。
Bureau 的新受保护请求会在线检查 Token;检查不可用时拒绝访问。退出撤销当前 Token,已开始的流式请求不强制中断。
### 管理端常用路由
| 路由 | 方法 | 用途 |
| --- | --- | --- |
| `/v1/bureaus/list` | `GET` | Bureau 列表 |
| `/v1/bureaus/create` | `POST` | 创建 Bureau,必须提供 `server_url` |
| `/v1/bureaus/pause` | `POST` | 暂停 Bureau |
| `/v1/bureaus/activate` | `POST` | 启用 Bureau |
| `/v1/bureaus/archive` | `POST` | 归档 Bureau |
| `/v1/bureaus/server/update` | `POST` | 更新 Bureau 唯一 Server 的服务入口 |
| `/v1/bureaus/current` | `GET` | 按 User Token 解析当前 Bureau |
| `/v1/accounts/tokens/issue` | `POST` | 签发 user_token |
| `/v1/env/list` | `GET` | 环境变量列表 |
| `/v1/env/upsert` | `POST` | 写入环境变量 |
| `/v1/env/remove` | `POST` | 删除环境变量 |
| `/v1/env/import` | `POST` | 批量导入 .env |
同一条 `/v1/{service}/{action}` 路由会根据 bearer 凭证决定身份:
- 不带 token:`guest`
- 带 `user_token`:`user`
- 带管理员会话 Token:`admin`
Action 自己声明允许哪些身份访问。默认是 `["user"]`;传 `[]` 表示免登录。
## 产品侧请求格式
### 认证头
```http
Authorization: Bearer
Content-Type: application/json
```
同一个 `/v1/ai/models`:
- `user_token` 访问时,只返回当前可调用模型
- 管理员会话 Token 访问时,返回全部代码注册模型,并附带 `env_requirements`
### `GET /v1/ai/models`
`user_token` 示例:
```json
{
"items": [
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"description": "DeepSeek text model",
"modalities": ["text", "stream"],
"tags": ["deepseek", "text"],
"price": ["输入:1 credit / 1K tokens", "输出:3 credits / 1K tokens"],
"meta": {},
"reasoning": {
"efforts": [
{ "id": "low", "name": "低" },
{ "id": "high", "name": "高" }
],
"default_effort": "low"
}
}
]
}
```
管理员会话 Token 示例:
```json
{
"items": [
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"description": "",
"modalities": ["text", "stream", "openai"],
"tags": ["deepseek", "text"],
"price": ["输入:1 credit / 1K tokens", "输出:3 credits / 1K tokens"],
"meta": {},
"env_requirements": [
{
"key": "DEEPSEEK_API_KEY",
"description": "deepseek API Key",
"required": true
}
]
}
]
}
```
### `POST /v1/ai/stream`
这是 `CityModel` 使用的版本化 Downcity Model Protocol transport。请求包含协议版本、模型 ID 和标准 `ModelCall`:
```json
{
"protocol_version": 1,
"model_id": "deepseek-v4-flash",
"call": {
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "你好" }] }
],
"reasoning": { "enabled": true, "effort": "high" }
}
}
```
Federation 通过 SSE 返回 `{ "protocol_version": 1, "event": ModelStreamEvent }`。产品代码通常把模型目录返回的 `CityModel` 交给 Agent,或调用 `city.ai.stream({ model, call })`;两者都直接消费同一标准事件流。
`reasoning.effort` 必须匹配最终执行模型目录中的 `reasoning.efforts[].id`。发生 fallback 时,AIService 按最终模型重新校验该档位。
AI 请求的用户和 Bureau 身份完全由 bearer `user_token` 解析,请求体不参与身份判断。
### `POST /v1/ai/chat/completions`
**OpenAI 兼容端点**。接收标准 OpenAI `chat/completions` 格式:
```json
{
"model": "deepseek-v4-flash",
"messages": [{ "role": "user", "content": "写一段欢迎语" }],
"stream": true
}
```
返回 SSE 流:
```
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"欢迎"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{...}}
data: [DONE]
```
使用 OpenAI SDK:
```ts
const openai = new OpenAI({
baseURL: "http://127.0.0.1:43127/v1/ai",
apiKey: "ub_xxx",
});
await openai.chat.completions.create({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "你好" }],
stream: true,
});
```
### `GET /v1/services`
```json
{
"items": [
{ "id": "ai", "name": "AI", "env": [] },
{ "id": "accounts", "name": "Accounts", "env": [] }
]
}
```
`env` 用于暴露该 service 声明的运行时环境变量需求,便于管理端或调试界面展示依赖项。
### `POST /v1/{service}/{action}`
```json
{ "text": "你好" }
```
## 管理端请求格式
### 认证头
```http
Authorization: Bearer
Content-Type: application/json
```
### `POST /v1/bureaus/create`
```json
{ "name": "Chrome Extension", "server_url": "https://bureau.example.com" }
```
```json
{ "bureau_id": "product_id", "name": "Chrome Extension", "server": { "bureau_id": "product_id", "server_url": "https://bureau.example.com", ... }, "state": "active", ... }
```
### `POST /v1/accounts/tokens/issue`
```json
{
"bureau_id": "product_id",
"user_id": "user_123",
"metadata": { "plan": "pro" },
"ttl": "7d"
}
```
```json
{ "user_token": "ub_xxx", "bureau_id": "product_id", "user_id": "user_123", ... }
```
### `POST /v1/env/upsert`
```json
{ "key": "DEEPSEEK_API_KEY", "value": "sk-xxx" }
```
## 常见状态码
| 状态码 | 什么时候返回 |
| --- | --- |
| `200` | 请求成功 |
| `401` | token 缺失、过期、签名无效 |
| `403` | Token 所属 Bureau 已暂停或没有调用权限 |
| `404` | 路由不存在、service 未注册 |
| `422` | model 不存在、action 未命中 |
| `500` | action 逻辑抛出未指定 statusCode 的错误 |
### 环境变量
Source: https://downcity.ai/zh/city-sdk-docs/reference/env-vars/
| 变量 | 用途 |
| --- | --- |
| `DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` | Federation 内部签发和校验 `user_token` |
| `DOWNCITY_CITY_DATABASE_URL` | 可选。指定 Federation 使用的数据库 URL |
| `OPENAI_API_KEY` | 示例 provider key,建议通过 Admin env 写入 Federation 数据库 |
| `OPENAI_BASE_URL` | 示例 provider base URL,建议通过 Admin env 写入 Federation 数据库 |
`fed deploy` 会交互式配置管理员 ID 和密码。密码只保存为 PBKDF2 摘要,管理员会话保存在服务端;不再使用管理员 key 环境变量。`DOWNCITY_FEDERATION_TOKEN_SIGNING_KEY` 和 `BETTER_AUTH_SECRET` 会在 Federation 首次启动时自动写入数据库的 `env` 表。
`DOWNCITY_CITY_DATABASE_URL` 不填时,默认数据库路径是:
```plaintext
.base/downcity.sqlite
```
## provider env 的使用方式
model handler 直接读取 `input.env`:
```ts
import {
AIChannel,
stream_openai_compatible_model,
type AIChannelStreamInput,
type AIChannelStreamResult,
} from "@downcity/federation";
class DeepSeekChannel extends AIChannel {
constructor() {
super({
id: "deepseek",
env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
base_url: "https://api.deepseek.com",
});
}
protected async stream(
input: AIChannelStreamInput,
): Promise {
const api_key = input.env("DEEPSEEK_API_KEY");
if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
return stream_openai_compatible_model(input, {
api_key,
base_url: this.base_url ?? "https://api.deepseek.com/v1",
});
}
}
const deepseek = new DeepSeekChannel();
```
所以实际约束很简单:
- env key 名称由你自己决定
- 建议使用大写下划线命名
- `stream()` 里通过 `input.env(key)` 读取
## 写入数据库
可信后端可以用 Embassy Admin 写入 Provider Key:
```ts
await embassy.admin.env.upsert({
key: "DEEPSEEK_API_KEY",
value: "sk-xxx",
});
```
这些值会保存到 Federation 数据库的 `env` 表。业务 Runtime 只从 Federation 自己的 env 表读取,不再回退到 `.env` 或进程环境变量。
详见 [AIChannel 环境变量](/zh/city-sdk-docs/guides/provider-env)。
## Agent SDK
### Agent SDK
Source: https://downcity.ai/en/agent-sdk-docs/
`@downcity/agent` provides the local `Agent` execution runtime. `@downcity/city` provides the
composition root, Workspace/Power resources, transports, and `RemoteAgent`. Both client shapes are
session-first: Agent composes execution capability, while Session owns a continuous conversation.
## One-minute mental model
```mermaid
flowchart LR
Host["SDK host"] --> City["City
resources and Power lifecycle"]
City --> Agent["Agent
identity and execution"]
Agent --> Session["agent.sessions.create({ workspace })
Session"]
Session --> Turn["Turn
one prompt execution"]
City --> Power["Power
extension capability"]
```
| Object | Owns | Does not own |
| --- | --- | --- |
| `Workspace` | Project files, env, Tools, and optional Shell | Agent identity, powers, Session persistence, and Sandbox implementation |
| `Shell` | Sandbox Provider, persistent Workspace Sandbox, and Shell Sessions | Chat Session identity and City lifecycle |
| `Agent` | Model, instructions, custom Tools, and Sessions | Power instances, Workspace resources, HTTP/RPC transports |
| `AgentSessions` | All Sessions owned by one Agent; each Session may receive a Workspace | Agent identity and Power definitions |
| `Session` | Conversation state, message history, execution order, and approvals | Project resource ownership |
| `City` | Workspace and Power resources, lifecycle, Storage, and transports | Session execution semantics |
| `SessionTurnContext` | Execution context and lifecycle for one Turn | Long-lived Agent state |
| `RemoteAgent` | Access to a server-side Agent and its Sessions | Server-side model configuration |
## Choose local or remote
| Scenario | Use |
| --- | --- |
| Agent and application run in the same Node.js process | `Agent` |
| The application injects models, Tools, powers, and Shell directly | `Agent` |
| Another process already exposes the Agent over HTTP/RPC | `RemoteAgent` |
| The client only creates Sessions, sends prompts, and subscribes | `RemoteAgent` |
Network serving does not belong to Agent. Create a `City`, add the Agent with `city.agents.add(agent)`,
then use `city.http()` or `city.rpc()` to expose the City.
## Recommended local composition
Shell is the standard entry for project commands and processes. This embedded example injects a microsandbox Provider directly into Shell:
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
import { Shell } from "@downcity/city";
const shell = new Shell({
sandbox_provider: new MicrosandboxProvider(),
});
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell,
runtime_path: "/path/to/downcity-runtime/project",
});
const agent = new Agent({
id: "repo-helper",
model,
});
// Session 在创建时选择 Workspace。
try {
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Summarize this repository" });
const result = await turn.finished;
console.log(result.text);
} finally {
await agent.dispose();
}
```
Remember this primary path:
```text
Agent + Workspace + Shell → agent.session() / agent.sessions.create({ workspace }) → Session → send() / prompt() → turn.finished → dispose()
```
`agent.dispose()` releases Session resources and the current City binding. City closes shared Power instances, lifecycle, and ActionSchedule resources. The project directory never receives `.downcity`.
## Model ownership
In pure SDK embedding, the host owns model instances:
- Set the default with `new Agent({ model })`.
- Or override it for one local Session with `await session.set({ model })`.
In a Downcity project, the CLI host resolves the model from project configuration and the City AIService catalog. Remote Sessions do not expose client-side model configuration; the server host always owns the model.
## Recommended reading order
1. [Local Agent quickstart](/en/agent-sdk-docs/local-agent/quickstart)
2. [Sessions overview](/en/agent-sdk-docs/sessions/overview)
3. [Shell and Sandbox](/en/agent-sdk-docs/local-agent/shell-reference)
4. [Tools and powers](/en/agent-sdk-docs/local-agent/tools-and-powers)
5. [Remote Agent](/en/agent-sdk-docs/remote-agent/quickstart)
6. [SDK Surface](/en/agent-sdk-docs/api-reference/sdk-surface)
7. [Agent SDK architecture](/en/docs/agent/sdk-architecture)
### Local Agent Quickstart
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/quickstart/
## Install
```bash
pnpm add @downcity/agent @downcity/city @downcity/federation @downcity/sandbox-microsandbox
npx microsandbox setup
```
`@downcity/sandbox-microsandbox` is the cross-platform Sandbox Provider. It uses a separate persistent microVM environment for each Workspace.
## Complete example
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { create_openai_compatible_model } from "@downcity/federation";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const model = create_openai_compatible_model({
id: "gpt-5",
upstream_model: "gpt-5",
base_url: "https://api.openai.com/v1",
api_key: process.env.OPENAI_API_KEY!,
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const agent = new Agent({ id: "repo-helper", model });
const city = new City({
workspaces: [workspace],
agents: [agent],
});
try {
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({
query: "Summarize the current repository structure",
});
console.log((await turn.finished).text);
} finally {
await city.close();
}
```
## Ownership
- City owns Powers and registered resources, but does not know the Workspace execution backend.
- Workspace owns its project resources, environment, and optional Shell.
- Shell owns its Sandbox Provider, persistent Workspace Sandbox, and Shell Sessions; `shell_id` and Chat `session_id` are independent.
- Agent owns Chat Sessions. A Chat Session borrows the selected Workspace but does not own its Shell or Sandbox.
Shell commands default to `target: "sandbox"` with the project mounted at `/workspace`. The same Workspace preserves HOME and installed tools across Chat Sessions. `target: "host"` requires a reason and approval. Sandbox startup failure never falls back to host execution.
Without Shell, Workspace still provides file and search tools. `city.close()` releases Agents, Powers, and Workspaces; Workspace disposal delegates to Shell, which stops Sandbox compute while retaining its persistent filesystem.
## Next steps
- [Shell and Sandbox](/en/agent-sdk-docs/local-agent/shell-reference)
- [Sessions overview](/en/agent-sdk-docs/sessions/overview)
- [Tools and powers](/en/agent-sdk-docs/local-agent/tools-and-powers)
### Agent Constructor Options
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/constructor-options/
The local Agent is constructed like this:
```ts
const workspace = new Workspace({
id,
path,
shell,
env,
});
const agent = new Agent({
id,
name,
description,
model,
instruction,
tools,
powers,
Session,
});
// Session 在创建时选择 Workspace。
```
## `id`
The stable identifier of the agent.
It affects the user-level Agent data path:
```text
~/.downcity/agents//...
```
Recommended properties:
- stable
- URL-safe
- not changed casually
That is because it directly controls the session storage partition.
## `name` and `description`
`name` is the user-visible Agent name and falls back to `id` when omitted or empty. `description` is a concise capability profile used by hosts for display and by Group dispatch when selecting members semantically.
```ts
const reviewer = new Agent({
id: "reviewer",
name: "Code Reviewer",
description: "Reviews code quality, test coverage, and delivery risks",
instruction: "Give concrete, actionable review suggestions.",
});
```
`id` and `name` become the Agent Identity block at the top of every Session system prompt:
```text
# Agent Identity
You are "Code Reviewer" (agent id: reviewer).
```
Both values are always given, so the model can use the readable name while hosts can still reconcile against the stable `id`.
`description` does not become a regular AgentSession system instruction and does not replace `instruction`: the former says what the Agent is suited for, while the latter controls how it works. Group dispatch sees only member `id/name/description`, not the complete `instruction`.
## `workspace` and the session directory
The project resource and security boundary available to the Agent. Workspace owns the project root, file and search tools, and an optional Shell:
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell,
});
```
Key points:
- macOS, Linux, and Windows use the same `Workspace`
- `path` is resolved to a real absolute directory during construction
- file and search tools remain confined to the Workspace
- Shell and file tools share the same project root
- `new Agent({ workspace })` sets the default directory for new sessions; it is not a binding
- a session's current directory can be switched at any time with `session.set({ workspace })` or by passing it to `get()`
- one Agent can work across multiple Workspaces, each Session keeping its own current directory, history, Shell, and power context
- multiple Agents may target the same directory, using distinct agent IDs and data partitions
`Workspace` requires only a stable `id`, the real project `path`, and optional Shell and env values. The implementation resolves Downcity's internal data root automatically:
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell,
});
// Session 在创建时选择 Workspace。
```
Workspace construction fails immediately when `path` is empty, missing, or not a directory.
The local implementation writes private data under `~/.downcity` by default. Tests and multi-instance hosts can override the root with `DC_PLATFORM_ROOT`; that environment variable is not part of the Workspace API.
## `instruction`
Static caller-provided instructions for the local SDK Agent.
```ts
new Agent({
id: "repo-helper",
instruction: [
"You are a concise code assistant.",
"Prefer direct file references when explaining code.",
],
});
```
Key points:
- `instruction` is static and cache-friendly
- the SDK does not render dynamic variables inside it
- the SDK does not read project prompt config files
- if you omit `instruction`, the SDK uses its minimal core instruction fallback
Hosts that need additional base instructions should pass them explicitly through `instruction`.
## `model`
The default `ModelClient` held by the Agent. Use a `CityModel` returned by the Federation catalog or create an in-process model through a Provider Adapter.
```ts
const model = create_openai_compatible_model({
id: "gpt-5",
upstream_model: "gpt-5",
base_url: "https://api.openai.com/v1",
api_key: process.env.OPENAI_API_KEY!,
});
new Agent({
id: "repo-helper",
model,
});
```
Key points:
- the SDK does not select, persist, or restore model IDs; it only holds `ModelClient` instances
- `CityModel` implements `ModelClient`, so Agent calls `stream(call, signal)` directly
- a Session without its own model falls back to the Agent model
- a local Session can override the Agent model with `session.set({ model })`
- effective model resolution always checks the Session model before the Agent model
## Workspace `env`
The project execution environment owned by the Workspace.
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "",
},
});
```
Key points:
- the SDK reads the project `.env` first, then applies explicit Workspace `env` overrides
- env belongs to Workspace rather than Agent
- if a host needs layered env merging, it should merge first and then pass the final result through `env`
- these values become the Workspace runtime env, but are not written back to the system environment automatically
### Updating env at runtime
You can update or replace Workspace environment variables at runtime:
```ts
workspace.set_env({ FOO: "1" });
workspace.get_env(); // => { FOO: "1" }
workspace.patch_env({ BAR: "2" }); // merge
workspace.patch_env({ FOO: null }); // null means delete
workspace.set_env({ ONLY: "ok" }); // replace all
```
Key points:
- `get_env()` returns a shallow snapshot
- `patch_env()` treats `null` / `undefined` as delete
- `set_env()` replaces the current Workspace env
- `agent.set_instruction()` affects only Sessions created afterwards and does not retroactively change existing in-memory Sessions
- restored Sessions regenerate from the Agent's current instruction and powers when `instruction.md` is absent; use `session.syncshot()` to refresh an existing Session and `session.snapshot()` to persist it
- Workspace env and power changes commit in existing Sessions at the next Session step checkpoint
- an in-flight provider request and its tool calls keep the env that was effective for the current step
- if the current Session turn has no later step, the queued change is committed when the next Session turn starts
- the Session timeline emits a completed action message when the change becomes effective
- Runtime changes live in memory only; they are not written back to the project `.env` or to the system environment
## `tools`
Additional caller-defined tools registered on the Agent.
Key points:
- Workspace provides file, search, and optional Shell tools as WorkspaceTools
- the host pushes one AgentTool per Power, named after that Power
- `AgentOptions.tools` provides caller-defined tools
- optional tools such as `ask_question` must be imported from `@downcity/agent/tools` and passed explicitly
- Agent owns the final merged collection shared by Sessions
- duplicate names across any source throw instead of silently overriding tools
This is a good fit when multiple sessions should share a stable default tool set.
## Workspace `shell`
Optional built-in shell capability.
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const agent = new Agent({ id: "repo-helper" });
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
runtime_path: "/path/to/downcity-runtime/project",
});
```
Key points:
- Shell is not a power
- Shell only mounts `shell_exec` and `shell_session`
- Workspace mounts `grep`, `find`, `read`, `write`, and `edit`, even without Shell
- session and turn context are wired internally
- approvals belong to a specific Session; use `session.interactions()`, `session.respond(...)`, and `session.set({ security })`
- file and search tools are always rooted in the Agent project and do not expose a host execution target
## Powers are not Agent constructor options
`AgentOptions` does not accept `powers`. After City receives `CityPowerRegistration` objects, every registered Agent automatically receives every Power:
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
const agent = new Agent({ id: "repo-helper", tools: {} });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
City owns unique Power instances, configuration projection, and lifecycle. Agent owns identity,
model, instructions, Tools, and Sessions. Web capabilities still require host configuration; see the
[Web Power guide](/en/powers-docs/builtins/web).
## `session_composer`
Pass a Composer instance when you only need to customize model input or context maintenance. It is an Agent-level concern that describes how this Agent talks to the model, and it is shared by every Session of that Agent.
```ts
import { Agent, DefaultSessionComposer } from "@downcity/agent";
const agent = new Agent({
id: "repo-helper",
session_composer: new DefaultSessionComposer(),
});
```
A Composer implementation must therefore be stateless: the Session and its context arrive through `compose(input)`, and derived data goes into the passed `derived` store. Do not cache anything Session-specific on the instance.
### What a Session Composer is
A Composer is the single place that answers what the model sees this step. The caller passes canonical history and the derived store in, and the Composer reads them to produce the model input:
| Method | Purpose |
| --- | --- |
| `initialize(input)` | Initialize its own derived schema in this Session's `session.db`; implementations without derived state may leave this empty |
| `compose(input)` | Return the `system`, `messages`, and `tools` for the current model Step |
| `advance_context(input)` | Try to advance derived state under a `usage_pressure` or `provider_context_limit` trigger; return `true` when something actually advanced |
`compose(input)` may:
- add, remove, or reorder system messages
- filter, transform, or inject model messages for the current Step
- change the tools visible to the current Step
Compaction is not a separate concept; it is one implementation of `advance_context()`. `FullHistorySessionComposer` is the built-in implementation that never advances derived state. To use a different compaction algorithm, extend `DefaultSessionComposer` and override `advance_context()`.
A Composer does not consume the Prompt Queue, control Turns, or modify canonical Messages. It may only write rebuildable derived tables namespaced as `composer__*`. Use `session_class` only when replacing Session behavior itself.
### Tools and Powers
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/tools-and-powers/
Tools and Powers both participate in Session execution, but ownership differs: Agent owns custom
Tools; City owns unique Power instances, configuration projection, and lifecycle.
## Custom Tools
A local Agent directly receives custom Tools:
```ts
const agent = new Agent({
id: "repo-helper",
tools: {
my_tool,
},
});
```
They are merged with the Tools of the Workspace selected by the Session. Name conflicts throw instead of silently replacing a Tool.
## Workspace and Shell
Workspace provides `grep`, `find`, `read`, `write`, and `edit`. Configure Shell explicitly when command execution is required:
```ts
import { Agent } from "@downcity/agent";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
import { Shell, Workspace } from "@downcity/city";
const agent = new Agent({ id: "repo-helper" });
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
runtime_path: "/path/to/downcity-runtime/project",
});
```
Shell adds `shell_exec` and `shell_session`. File and search Tools remain restricted to the Workspace root. Approvals and asynchronous questions belong to a specific Session and use `session.interactions()` and `session.respond(...)`.
## City provides Powers
The Agent constructor does not accept Powers. Once registrations are provided to City, every Agent receives them automatically:
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
const agent = new Agent({ id: "repo-helper", model });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
City automatically exposes every registered Power to every Agent. A running City can update the
collection through `city.powers.add()` and `city.powers.remove()`.
Each Power ID identifies one instance inside a City with a City-level `initialize/dispose` lifecycle.
Agents and Workspaces provide call context; they do not own Power lifecycle.
## City-level configuration
The host provides one Config store for each Power. Configuration does not enter Agent or determine
instance count:
```ts
const city = new City({
power_host: {
config: (power_id) => open_power_config_store(power_id),
},
});
```
Each Power ID has one configuration inside a City. A Power models its own account collection and
selection rules when needed; City does not provide a generic named-configuration layer. After saving Config, the Power
refreshes any affected long-lived resources.
## Calling Powers
Host code calls Actions and Hooks through an Agent/Workspace execution entry:
```ts
const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({
power: "memory",
action: "search",
payload: { query: "project conventions" },
});
const enriched = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", enriched);
```
When a Power declares Actions, the host pushes one AgentTool named after that Power. The model calls it with `{ action, args }`, or omits `action` to read the action index with each schema. Power `system()` text is contributed at the `session.system_context` checkpoint.
## Direct communication through PowerContext
Power author APIs come from `@downcity/city/power`. Action, Hook, System, and Availability receive a `PowerContext` with restricted `city`, `agent`, `workspace`, `session`, `turn`, `config`, and `storage` handles.
`context.session` and `context.agent.sessions` are directly callable in-process objects; a Power does not need to retain only `session_id` and query the host again. Use `context.workspace.files` for Workspace files and `context.storage.files` for private Power files.
## HTTP
A Power may declare structured HTTP routes. City mounts and authenticates them and injects the request-specific PowerContext. Powers do not create independent servers.
## See also
- [Power overview](/en/agent-sdk-docs/powers/overview)
- [Power lifecycle](/en/agent-sdk-docs/powers/lifecycle)
- [Power actions](/en/agent-sdk-docs/powers/power-actions)
- [Built-in Powers](/en/powers-docs)
- [Asynchronous user interactions](/en/agent-sdk-docs/sessions/approvals)
### Shell and Sandbox
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/shell-reference/
Downcity separates four responsibilities:
```text
City → Workspace
Workspace → Shell
Shell → SandboxProvider + persistent Workspace Sandbox + Shell Sessions
Chat Session → caller only
```
A Workspace gets one isolated, persistent Sandbox. Every Chat Session using that Workspace shares its Sandbox HOME, installed tools, and caches. Different Workspaces never share writable Sandbox state. Shell Sessions are independent resources identified by `shell_id`; they are not Chat Sessions.
## Install and setup
```bash
pnpm add @downcity/agent @downcity/city @downcity/sandbox-microsandbox
npx microsandbox setup
```
The default Provider uses microsandbox microVMs and OCI images on supported macOS, Linux, and Windows hosts.
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
});
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
```
An embedded host that does not use City passes `sandbox_provider` to Shell and `runtime_path` to Workspace. City only supplies the runtime path automatically; it never selects or owns the Provider.
## Execution targets
Shell has only two targets:
- `sandbox` is the default. Commands run in the Workspace microVM, with the project mounted at `/workspace`.
- `host` runs with full host permissions and requires a non-empty reason plus host approval.
```ts
shell_exec({
cmd: "open ./report.pdf",
target: "host",
reason: "Open the generated report in the host desktop application.",
});
```
There is no partial host-path allowlist, intermediate permission mode, or silent fallback. If the Sandbox is unavailable, execution fails instead of switching to the host.
## Paths and the Workspace mount
File and search tools run in the host process, while Shell commands run inside the Sandbox. Both accept a sandbox-internal path such as `/workspace/src/main.ts`: the sandbox path is mapped back to the project root before the path policy runs, so `/workspace/src/main.ts` and `src/main.ts` address the same file. Results always report host-side paths. Paths outside the mount, such as `/etc/hosts`, are still rejected.
## Tools and lifecycle
`Shell` provides `shell_exec` for short non-interactive commands and `shell_session` for long-running or interactive processes. `shell_session` supports `start`, `send`, `read`, `wait`, `list`, and `stop`.
Closing a Workspace disposes its Shell. Shell first closes its Shell Sessions and then stops Sandbox compute, preserving the Sandbox filesystem. Recreating the same Workspace under the same City storage reconnects to that environment. Only an explicit `shell.reset_sandbox()` deletes HOME and installed tools.
The Workspace mount is fixed when the Sandbox is created. If a stable Workspace ID is later bound to a different host path or City runtime path, Downcity rejects the connection and requires an explicit reset rather than silently mounting unexpected files.
## Environment
Sandbox commands receive the explicit Workspace env and Downcity execution identifiers; they do not inherit the complete host `process.env`. Host commands start from the host environment and then apply Workspace env. Secret Downcity authentication variables are removed in both cases.
`workspace.set_env()` and `workspace.patch_env()` affect subsequent commands. Existing processes keep the environment captured when they started.
## Failure messages
When a command exits with a nonzero code or its Shell status is `failed`, the tool returns `success: false` and an explicit `error` containing the exit code, when available, and up to 2000 characters from the end of its output. The returned output remains available in `output`. If an interactive session has no output in the current chunk, the error uses its latest output preview.
An unapproved host request returns `Host execution was not approved.` A missing approval gateway or missing call identity can also produce this result; the message does not imply that the user explicitly rejected the request.
Local Agent Session IDs are case insensitive; creation and lookup normalize them to lowercase. New and forked Sessions use the same 12-character random format, such as `k7m2x9p4w6rt`, excluding ambiguous `0/o` and `1/i/l`. Generated IDs are used directly as directory names. Storage still handles special path characters and Windows reserved names in custom IDs.
Existing Sessions require offline migration before using the new rules. The script previews changes by default:
```bash
node public/packages/agent/scripts/migrate-session-ids.mjs /path/to/agent-root
# Stop Downcity and review the preview and external references before applying:
node public/packages/agent/scripts/migrate-session-ids.mjs /path/to/agent-root --apply --offline
```
The script checks for case collisions within each origin partition, backs up active and archived trees, updates database identities, message origins and structured attachment paths, then renames directories. Existing IDs are lowercased rather than shortened. The backup's `manifest.json` supplies ID and path mappings. Owners of Task, Messenger, Power and custom Composer references must update their external data using that mapping before restarting Downcity. If migration fails, keep Downcity offline and restore both Session trees from the backup.
## Approval
Every `host` start and every stdin write to a host Shell Session goes through the calling Chat Session's approval gateway. Approval belongs to the tool call, while the resulting Shell Session remains a Workspace Shell resource.
```ts
const pending = await session.interactions();
await session.respond({
interaction_id: pending[0].request.interaction_id,
response: {
type: "approval",
outcome: "resolved",
payload: { decision: "approved" },
},
});
```
`ask` is the default approval mode. `always-allow` affects new host approval requests in that Chat Session only; it does not change the Sandbox boundary.
### Expose City over RPC and HTTP
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/http-and-rpc/
External services belong to `City`. Agents remain the execution subjects; City owns the
network listeners and routes requests to its Agents and Workspaces.
```bash
pnpm add @downcity/agent @downcity/city
```
## Start a City transport
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({ id: "project", path: process.cwd() });
const agent = new Agent({ id: "assistant", model });
const city = new City({ workspaces: [workspace] });
city.agents.add(agent);
const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 });
const http = await city.http({ host: "127.0.0.1", port: 5314 });
console.log(rpc.binding()?.url); // rpc://127.0.0.1:15314
console.log(http.binding()?.url); // http://127.0.0.1:5314
await city.close();
```
`City` owns one HTTP transport and one RPC transport. Both route by Agent ID and Workspace ID:
- HTTP: `http://127.0.0.1:5314/agents/assistant/workspaces/project`
- RPC: `rpc://127.0.0.1:15314/assistant/project`
The protocol is NDJSON for RPC. Authentication, CORS, and other host-level policies belong to
the City host.
## Start both transports together
```ts
await city.listen({
http: { host: "127.0.0.1", port: 5314 },
rpc: { host: "127.0.0.1", port: 15314 },
});
await city.close();
```
`city.listen()` returns after both listeners are ready. Use `city.http()` or `city.rpc()` when
the transports need independent lifecycle control.
## Relationship with RemoteAgent
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "http://127.0.0.1:5314/agents/assistant/workspaces/project",
});
const session = await agent.sessions.create();
```
`RemoteAgent` consumes the City endpoint; it does not create or configure the server-side Agent.
## Related docs
- [Local Agent quickstart](/en/agent-sdk-docs/local-agent/quickstart)
- [RemoteAgent quickstart](/en/agent-sdk-docs/remote-agent/quickstart)
- [City transport](/en/city-sdk-docs/en/packages/city/transport)
### Example: Embed a Local Agent in a Node Service
Source: https://downcity.ai/en/agent-sdk-docs/local-agent/example-node-service/
One very common pattern looks like this:
1. Your Node service process creates an `Agent`.
2. The service creates or loads a session at startup.
3. An HTTP handler receives a request and turns it into `session.prompt()`.
## Minimal embedding mode
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
const agent = new Agent({ id: "repo-helper", model });
const workspace = new Workspace({
id: "project",
path: process.cwd(),
});
const session = await agent.sessions.create({ workspace });
```
### Power Overview
Source: https://downcity.ai/en/agent-sdk-docs/powers/overview/
A Power is Downcity's unified extension product. It may provide Actions, Hooks, Resolves, System
content, Availability, HTTP routes, lifecycle, and optional Mainview or Config surfaces.
Powers do not belong to Agents. City owns the catalog, main modules, one instance per Power ID,
and the complete lifecycle. Every registered Power is exposed to every Agent. A Session captures a
stable Hook view at execution checkpoints.
## SDK assembly
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
const registrations = create_builtin_power_registrations();
const city = new City({ workspaces: [workspace], powers: registrations });
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
```
`CityOptions.powers` or `city.powers.add()` registers Powers. `city.agents.add()` only registers
the Agent.
## PowerContext
Actions, Hooks, System providers, and Availability checks use a scope-specific `PowerContext`. It
provides restricted handles for:
- `city`: Embassy and cross-Power collaboration ports.
- `agent`: identity, instructions, and Sessions.
- `workspace`: files, Shell, environment, and stable ID.
- `session` / `turn`: direct communication handles when the call belongs to a Session or Turn.
- `config`: a read-only snapshot of the current Power's City-level configuration.
- `storage`: private storage for the current Agent/Power scope.
Object handles support direct in-process communication; IDs provide stable identity and
serialization. A Power does not need to receive only a `session_id` and resolve it through the host.
## Unique instances and configuration
One City creates one instance per Power ID with one `initialize/dispose` lifecycle. Agent and
Workspace belong only to each call Context. Each Power ID also has one City-level configuration, and
every Agent receives the same `context.config` snapshot.
## Recommended reading
1. [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle)
2. [Power Actions](/en/agent-sdk-docs/powers/power-actions)
3. [Power Hooks](/en/agent-sdk-docs/powers/power-hooks)
4. [Power Configuration](/en/agent-sdk-docs/powers/configuration)
5. [Power Development](/en/agent-sdk-docs/powers/power-development)
6. [Built-in Power manuals](/en/powers-docs)
### Power Design Patterns
Source: https://downcity.ai/en/agent-sdk-docs/powers/design-patterns/
Not every power should look the same.
In current Downcity, a healthy power design usually starts by choosing one primary shape.
## 1. Action-first power
Use this shape when the main goal is:
- explicit user-triggered capability
- clear command or API entry points
- bounded one-shot work
Typical examples:
- `skill`
- `sound`
## 2. Hook or resolve power
Use this shape when the power should participate in an existing runtime chain.
Typical examples:
- the built-in authorization capability in `messenger`
- automatic ASR in `sound`
- role resolver style powers
## 3. System-text power
Use this shape when the power's main job is to add stable runtime guidance.
Typical examples:
- `skill`
- `web`
- `memory`
## 4. Lifecycle-owned runtime power
Use this shape when the power must own long-lived runtime state.
Typical examples:
- `messenger`
- `task`
- `shell`
ActionSchedule is intentionally not modeled as a power; it is a shared Agent action module.
This is the right choice when the capability must:
- start
- stop
- recover
- keep in-memory state
- poll or react over time
## 5. Runtime HTTP power
Use this shape when the power mainly exposes runtime status or control surfaces through HTTP.
This shape is suitable when a custom Power contributes dedicated status endpoints while reusing host authentication and lifecycle.
## One practical rule
Do not start from “where can I quickly place this code”.
Start from:
- is this an explicit command
- is this chain participation
- is this stable system guidance
- is this lifecycle-owned runtime state
- is this HTTP exposure
## Related docs
- [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle)
- [Power Actions](/en/agent-sdk-docs/powers/power-actions)
- [Power Hooks](/en/agent-sdk-docs/powers/power-hooks)
- [Power Runtime Composition](/en/powers-docs/examples/power-runtime-composition)
### Power Lifecycle
Source: https://downcity.ai/en/agent-sdk-docs/powers/lifecycle/
City owns Powers. `city.powers.add(new MyPower())` registers one unique instance. A Power ID cannot
be registered twice in one City, and Agents do not store Power instances or Registries.
## Two lifecycle checkpoints
- `initialize(context)` runs once when the Power enters City. Register host Actions and initialize resources owned by the Power here.
- `dispose(context)` runs once when the Power is removed or City closes. Release those resources here.
```ts
class ProviderPower extends Power {
readonly name = "provider";
async initialize({ storage }: PowerLifecycleContext) {
await this.provider.open(storage.path);
}
async dispose() {
await this.provider.close();
}
}
```
Both methods receive the same stable `PowerLifecycleContext`. Agent, Session, and Workspace entry or
removal do not trigger Power lifecycle callbacks. There is no generic `connect/disconnect` protocol.
`city.powers.add()` explicitly rejects when initialization fails. The failed instance never enters the
execution Registry and cannot contribute Actions, Hooks, or System content. City retains an observable
`status: "error"` snapshot with `last_error` in `city.powers.snapshots()`. A host should isolate that
Power and continue starting other subjects instead of treating an optional Power as an application-wide
startup dependency. A successful retry for the same Power ID replaces the error snapshot with `ready`.
## Call context
City freshly projects a `PowerContext` for every Action, Hook, System, and Availability call. Its
`agent`, `workspace`, and optional `session/turn` handles can be used directly. Workspace is the project
resource for the current call; it is not a Power lifecycle boundary or a default resource-isolation key.
Long-lived connections, timers, schedulers, and workers must follow their actual domain ownership. A
browser Provider may be created lazily for an Agent, a chat channel may be keyed by account, and a
scheduler may follow task definitions. `dispose` must ultimately close every resource.
## Execution leases
A Session captures an immutable Power execution view at each Step checkpoint. New Steps stop seeing a
removed Power immediately. An existing Step may finish with its captured instance, and City calls
`dispose` only after the final execution lease is released.
`city.close()` stops Sessions before releasing Powers and Workspaces, and aggregates cleanup errors. A
single Power `dispose` failure does not prevent other Powers from cleaning up.
### Power Configuration
Source: https://downcity.ai/en/agent-sdk-docs/powers/configuration/
Each Power ID has exactly one host configuration inside a City:
```text
host stores Power Config
→ City owns one Power instance
→ City projects a configuration snapshot for each execution
→ Power reads the current PowerContext.config
```
```ts
const city = new City({
powers: [web_power],
workspaces: [workspace],
power_host: {
config: (power_id) => open_power_config_store(power_id),
notifications: (power_id, agent_id) => open_notifications(power_id, agent_id),
},
});
city.agents.add(agent);
```
`PowerConfigStore.get()` synchronously returns the complete current snapshot, while `set(config)`
atomically replaces it. Agents store neither Power references nor configuration; every Agent sees the
same Power configuration. A Power that needs multiple accounts or tenants models them inside its own
Config instead of relying on a generic City named-configuration layer.
A Power registers Config actions during `initialize()`. City injects the Power's unique `get/set`
store when `city.powers.invoke_config()` calls one. Actions, Hooks, System providers, and Availability
checks read an immutable snapshot from the current `PowerContext.config`.
After a Config save, the Power should release or refresh affected long-lived resources. Never store
connections, callbacks, or client objects in Config. The Power still owns them by an actual domain key,
such as an account or Agent, and ultimately releases them in `dispose()`.
### Power Actions
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-actions/
An Action is a Power's explicit business entry point. It consists of a description, optional input
schema/command/API mapping, timeout, and `execute` function.
```ts
import { create_action } from "@downcity/city/power";
import { z } from "zod";
const status = create_action({
description: "Read the current execution scope.",
input_schema: { zod: z.object({}) },
timeout_ms: 30_000,
execute: async ({ context }) => ({
success: true,
data: {
agent_id: context.agent.id,
workspace_id: context.workspace.id,
session_id: context.session?.id ?? null,
turn_id: context.turn?.id ?? null,
tool_call_id: context.call.tool_call_id ?? null,
},
}),
});
```
Each call receives one read-only input, `context`. It carries the restricted City, Agent,
Workspace, Session, Turn, Config, and Storage handles along with the call identity.
`context.call.tool_call_id` is the model-assigned Tool Call ID (empty for non-tool entries), and
`context.abort_signal` is the cancellation signal for this call.
Actions should pass `context.abort_signal` to network requests, polling, and long-running work.
`timeout_ms` only triggers cooperative cancellation; underlying operations must observe the signal.
The returned `PowerActionResult` must be JSON-serializable.
An Action may return additional current-Turn content through `messages`. Text/file/data Parts with
`role: "agent"` are appended to the current Agent Message. Text/context/file Parts with `role: "user"`
are ephemeral input for the next model step and never impersonate a persisted Session Message. The
Session domain consistently uses `agent`; only the model protocol boundary uses `assistant`.
`command` maps Commander input to a payload, while `api` maps an HTTP request. They do not create a
second business implementation; both end at the same `execute` function.
Models call Actions through the Power's own tool, whose name is the Power name. Power code may use
`context.city.powers.run_action()` for another Power registered in City. Application code
creates an explicit execution view with
`city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` and calls `run_action()` on it.
### Power Call Surfaces
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-call-surfaces/
Powers expose four call surfaces for different callers:
| Surface | Caller | Purpose |
| --- | --- | --- |
| `city.powers` | Host application | Add or remove unique instances and invoke host/config actions |
| `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` | Host execution code | Call Actions, Availability, or Hooks in one Agent/Workspace scope |
| `context.city.powers` | Power code | Call another Power Action, pipeline, or effect in the current scope |
| `skill` | Model in a Session | Call a Power Action provided by City |
## City management surface
```ts
city.powers.add(skill_power);
console.log(city.powers.snapshots());
await city.powers.remove("skill");
```
`city.powers.invoke()` and `invoke_config()` call host-management Actions registered during `start()`;
they are not Session execution Actions.
## Workspace execution surface
```ts
const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({
power: "skill",
action: "lookup",
payload: { name: "frontend-design" },
});
```
This surface explicitly binds an Agent and Workspace, allowing City to create the correct
`PowerContext`.
## PowerContext
```ts
const result = await context.city.powers.run_action({
power: "skill",
action: "list",
payload: {},
});
await context.city.powers.effect("audit.observe", result.data ?? null);
```
`context.session`, `context.agent.sessions`, and `context.workspace` are directly callable handles.
IDs are reserved for stable identity and serialization.
## Model Tool
Each Power with Actions compiles into its own model-facing tool whose name is the Power name. There is no
shared `plugin_call` entry: the model calls the `skill` tool here, and `ai` for the City-owned image and
speech capabilities.
```ts
skill({
action: "lookup",
args: { name: "frontend-design" },
});
```
The Tool is always bound to the current Session's Agent/Workspace execution lease and cannot observe
another Agent's bindings.
### Power Hooks and Resolve
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-hooks/
This area is easy to misunderstand because:
- all four live inside power definitions
- all four look like "register a function"
- but their runtime semantics are very different
If you only remember one line, start here:
- `pipeline`: rewrite a value
- `guard`: block a flow
- `effect`: run side effects
- `resolve`: produce one authoritative answer
## Start with the comparison
| Type | Who triggers it | Returns a value | Multiple handlers allowed | What failure means | Best fit |
| --- | --- | --- | --- | --- | --- |
| `pipeline` | host code calls it | yes | yes | throws and stops the current call | progressively transform one value |
| `guard` | host code calls it | no | yes | any thrown error blocks the flow | pre-checks and authorization |
| `effect` | host code calls it | no | yes | any thrown error stops the current effect call | logging, analytics, syncing |
| `resolve` | host code calls it | yes | no, exactly one | missing, duplicate, or disabled resolution fails | provide one final answer |
## The most important thing to remember
Declaring a hook inside a power does not make it run automatically.
What actually happens is: host code explicitly calls a named point at a specific time.
For example:
```ts
const value = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", value);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", value);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
So the clean mental split is:
- the power defines an extension point handler
- the host decides when that extension point executes
If the host never calls that point, the hook never runs.
Hooks are declared by Power instances and dispatched by the City-owned Registry for the current Agent/Workspace scope. A Session captures one execution lease at each checkpoint, so the call observes a stable Power set; concurrent unbinding waits for active leases to finish.
Inside a Power, `context.city.powers` exposes only `pipeline()`, `effect()`, and `run_action()` for collaboration. `guard` and `resolve` remain host-control and authority surfaces and are called from host execution entries such as `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })`.
## Why `resolve` deserves separate attention
Many readers naturally treat `resolve` as just another hook flavor.
But it is really closer to:
- an authority point
- a "who gives the final answer?" question
That is why it differs from the other three:
- the first three allow several powers to participate
- `resolve` allows exactly one power to be the final authority
This is also why:
- duplicate registration for one resolve point throws
- missing resolvers throw
- a disabled resolver power also causes failure
## Callbacks after a Session Turn finishes
`Agent` automatically emits `session.turn_committed` when a Turn completes, fails, or stops. The Session remains available for later input.
Register an application callback through `AgentOptions.hooks`:
```ts
import { Agent, SESSION_HOOK_POINTS, type SessionTurnCommittedHookValue } from "@downcity/agent";
import type { JsonValue, ToolCallContext } from "@downcity/type";
const agent = new Agent({
id: "reviewer",
model,
hooks: {
pipeline: {},
guard: {},
effect: {
[SESSION_HOOK_POINTS.turn_committed]: [Object.assign(async (value: JsonValue, context: ToolCallContext) => {
const event = value as unknown as SessionTurnCommittedHookValue;
await result_outbox.enqueue({
idempotency_key: event.completion_id,
session_id: context.session_id,
turn_id: event.turn_id,
status: event.status,
text: event.result.text,
});
}, { handler_id: "report_outbox" })],
},
},
});
```
`result_outbox` is an application-provided delivery port. Events also include `result.success`, optional `result.error`, and canonical `messages` for the Turn. Select behavior using `context.session_origin` and persisted business references; retain each request's destination separately.
Before closing the Turn's canonical messages, Agent saves its result and recipient identities in a private write-ahead journal. A journal write failure rejects `turn.finished` and leaves recoverable state rather than acknowledging success. If the process stops after journaling but before finalization, recovery reuses the saved result; submitting the same `request_id` does not run the model again. A fulfilled `turn.finished` does not acknowledge callback completion or external delivery. Callback delivery and delegated-result delivery retry independently with exponential backoff from one to sixty seconds.
Every completion callback requires a nonempty, unique `handler_id` that stays stable across restarts. Events retain their original recipient set, and each handler acknowledges independently. Temporarily removed handlers remain pending, and reordering handlers does not change ownership. An acknowledged handler is not replayed because another handler fails. Power completion Hooks also declare `handler_id`; the SDK namespaces it with the Power name.
Delivery is at least once: a crash after an external side effect but before local acknowledgement can still cause replay. Make side effects idempotent with `completion_id`. Return after accepting work into your delivery queue, and pass `context.abort_signal` to cancellable I/O. Each callback times out after thirty seconds by default. Agent shutdown aborts active callbacks and stops waiting, retaining unacknowledged records for recovery. JavaScript cannot forcibly terminate a function that ignores cancellation; the same recipient is not retried concurrently in that process until the original invocation settles. Functions are registered again on startup, not serialized.
Recovery requires persistent Storage. Custom hosts should assemble all agents, Workspaces, Powers, and subscriptions before calling `await agent.resume_work()` to resume pending completion events and delegated work. CLI and Desktop already do this. Manually invoking an arbitrary Power effect does not receive this persistent-delivery behavior.
Messenger uses the same completion Hook to enqueue final Channel output into its reliable Outbox, including later Turns triggered by delegated results. Background replies retain the original calling Turn's IM message and thread. Inputs merged into one Turn deduplicate replies by Conversation. Failed Turns send a brief failure notice; stopped Turns do not send automatically. Appending an Agent Message alone does not trigger a Turn-completion callback.
## Recommended reading order
1. [Mental model overview](/en/agent-sdk-docs/powers/hooks/overview)
2. [Call flow](/en/agent-sdk-docs/powers/hooks/call-flow)
3. [Pipeline](/en/agent-sdk-docs/powers/hooks/pipeline)
4. [Guard](/en/agent-sdk-docs/powers/hooks/guard)
5. [Effect](/en/agent-sdk-docs/powers/hooks/effect)
6. [Resolve](/en/agent-sdk-docs/powers/hooks/resolve)
7. [How to choose](/en/agent-sdk-docs/powers/hooks/choose-the-right-one)
## Jump straight to scenarios
- [One full message flow](/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [Why role resolution must stay unique](/en/agent-sdk-docs/powers/hooks/examples/role-resolution-flow)
- [Common bad vs good designs](/en/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design)
## Related docs
- [Power overview](/en/agent-sdk-docs/powers/overview)
- [Power actions](/en/agent-sdk-docs/powers/power-actions)
- [Custom power](/en/agent-sdk-docs/powers/custom-power)
### Hooks Overview
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/overview/
The best way to understand this group is not by what the code looks like, but by what actually happens at runtime.
## Shared pattern
All four share the same high-level pattern:
- the power declares a handler
- the host later calls a named point
For example:
```ts
const next = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", value);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", next);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", next);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
In this example, the host is what triggers execution, not the power by itself.
## Biggest difference between them
### `pipeline`
The key idea is: receive one value, then hand back a new value.
### `guard`
The key idea is: allow the flow to continue, or throw and stop it.
### `effect`
The key idea is: keep the main value untouched and only do side effects.
### `resolve`
The key idea is: only one power is allowed to be the final authority for the answer.
## Think of them as four kinds of questions
### `pipeline` asks
"Should this value be transformed before the next step?"
### `guard` asks
"Is this value allowed to continue?"
### `effect` asks
"Now that this happened, should we log, sync, or observe something?"
### `resolve` asks
"Who gives the final answer for this point?"
## Why `resolve` is the easiest one to misuse
Many people instinctively write it as if several powers can each contribute a little.
But `resolve` is not:
- a collaborative answer-building point
It is:
- a single-authority answer point
That makes it a good fit for things like final role lookup, final provider choice, or final routing decisions, not step-by-step enrichment.
## Next reading
- [Call flow](/en/agent-sdk-docs/powers/hooks/call-flow)
- [Pipeline](/en/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/en/agent-sdk-docs/powers/hooks/guard)
- [Effect](/en/agent-sdk-docs/powers/hooks/effect)
- [Resolve](/en/agent-sdk-docs/powers/hooks/resolve)
### Call Flow
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/call-flow/
This is the most important page because it removes the biggest misconception first:
- hooks do not run automatically just because they are registered
- hooks run when host code explicitly calls a named point
## Two roles
To understand hooks and resolve points, separate these two roles:
### power author
Defines:
- which point has a handler
- what input the handler receives
- what it returns or when it throws
### host author
Decides:
- when to call the point
- which value to pass in
- what to do with the result afterward
## One minimal call flow
```ts
const value1 = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", value1);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", value1);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
Read this flow step by step:
1. `pipeline` receives `input` and may return a richer `value1`
2. `guard` decides whether `value1` is allowed to continue
3. `effect` records side effects without rewriting `value1`
4. `resolve` answers a separate single-authority question
## Why the first three are chain points
Because the most common pattern is:
- the main flow reaches a specific step
- the host hands the current value to powers
- powers participate at that exact step
They behave like extension slots in a running pipeline.
## Why `resolve` feels more like a final lookup
Because it is usually not about continuing to transform one shared value.
It is about:
- the host now needs one final answer
- it asks a named point to provide that answer
For example:
- what is the user's effective role
- which provider should be selected
- which component is the final owner of this request
## What happens when a power is disabled
This depends on the point type.
### `pipeline / guard / effect`
If one registered power is currently disabled, its handler is skipped.
That means:
- the point still runs
- but that disabled power does not participate
### `resolve`
If the power owning the single resolver is disabled, the point does not silently continue.
Instead:
- resolution fails
That is because the point requires one authoritative answer.
## What happens when no handler exists
### `pipeline / guard / effect`
If no handlers are registered:
- `pipeline` returns the input unchanged
- `guard` passes immediately
- `effect` ends immediately
### `resolve`
If no resolver is registered:
- it throws
That is because the host explicitly asked a question that must produce an answer.
## The two key takeaways
- hooks mean "the host controls timing, powers provide insertion logic"
- `resolve` means "the host asks for one final answer, and exactly one power owns it"
## Next reading
- [Pipeline](/en/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/en/agent-sdk-docs/powers/hooks/guard)
- [Effect](/en/agent-sdk-docs/powers/hooks/effect)
- [Resolve](/en/agent-sdk-docs/powers/hooks/resolve)
### Pipeline
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/pipeline/
The core semantic of `pipeline` is:
- receive one value
- return a new value
- the next handler receives the previous handler's output
## It is not broadcast, it is relay
This matters a lot.
If three powers all attach to one `pipeline` point, the runtime model is not:
- all three see the original input independently
It is:
- the first transforms it and passes it on
- the second transforms that result
- the third transforms the next result
So `pipeline` is much closer to an assembly line than a fan-out event.
## Minimal example
```ts
const next = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", {
text: "Please summarize this page",
});
```
If one power is written like this:
```ts
hooks: {
pipeline: {
"chat.enrich_message": [
async ({ value }) => {
const body = value as Record;
return {
...body,
source: "web",
};
},
],
},
}
```
then the host receives a `next` value with the new `source` field attached.
## Three especially good uses
### add fields
For example:
- source
- pageTitle
- locale
### normalize shape
For example:
- normalize booleans
- turn empty strings into `null`
- collapse aliases into stable field names
### progressive enrichment
For example:
- one layer adds source context
- another adds session context
- another adds default policy
## What it is not ideal for
### permission blocking
That fits `guard` better because the point is not to rewrite a value, but to decide whether the flow may continue.
### plain logging
That fits `effect` better because there is no reason to return a new value.
### one final authoritative answer
That is `resolve`, not `pipeline`.
## Stable implementation practices
- prefer returning a complete next object instead of relying on hidden shared state
- keep the input-output shape more stable over time
- keep it focused on value transformation instead of side effects
## When to split responsibilities
If your logic starts needing:
- a lot of I/O
- long-lived state
- both blocking and recording responsibilities
that is often a sign to keep transformation in `pipeline` and move the rest into `guard`, `effect`, or power `lifecycle`.
## Related scenarios
- [One full message flow](/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [MessageAugmentPower scenario](/en/powers-docs/examples/message-augment-power)
### Guard
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/guard/
The core semantic of `guard` is simple:
- if the flow is allowed, return nothing
- if the flow is not allowed, throw
## Its job is not to transform values
Many people instinctively start modifying the input inside a guard.
But the clean responsibility of a guard is only:
- decide whether the current flow may continue
So if your main goal is "rewrite the input a bit," that belongs in `pipeline`.
## Minimal example
```ts
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", {
reviewed: true,
});
```
A power can define that point like this:
```ts
hooks: {
guard: {
"review.require_checked": [
async ({ value }) => {
const body = value as { reviewed?: unknown };
if (body.reviewed !== true) {
throw new Error("review is required before continuing");
}
},
],
},
}
```
## How multiple guard handlers run
If several guards attach to the same point:
- they run in registration order
- once one throws, the rest do not continue
So it behaves like a sequence of checkpoints.
## Common good use cases
### permission checks
For example:
- does the user have a role
- does the request have permission
### preconditions
For example:
- is a field required
- is some prior state already ready
### safety boundaries
For example:
- must this request be explicitly confirmed first
- must this input be reviewed before proceeding
## Poor use cases
### "continue, but also log something"
Split the logging into `effect`.
### "conditionally add default values"
That belongs in `pipeline`.
### "provide the final decision answer"
That belongs in `resolve`.
## What happens when a power is disabled
If one guard power is currently disabled:
- its handler is skipped
- the other active guards still run
So the runtime meaning is:
- active guards participate
- disabled guards do not
## Stable practices
- make the thrown error explain clearly why the flow was blocked
- avoid hiding lots of side effects inside guards
- keep value transformation and blocking logic separate
## Related scenarios
- [One full message flow](/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [ReviewGuardPower scenario](/en/powers-docs/examples/review-guard-power)
### Effect
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/effect/
The core semantic of `effect` is:
- keep the main value unchanged
- do side effects only
## What counts as a side effect
Typical examples are:
- logging
- recording observed state
- syncing something elsewhere
- sending analytics events
So the point is not to produce a new main result.
The point is to do something in response to the current step.
## Minimal example
```ts
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", {
event: "incoming_message",
session_id: "sess_001",
});
```
A power can define it like this:
```ts
hooks: {
effect: {
"audit.observe": [
async ({ value, power }) => {
console.log(`[${power}] observed`, value);
},
],
},
}
```
## It resembles an event bus, but not completely
Many people think of `effect` as a generic event bus.
That is not entirely wrong, but two details matter:
- it is still a named point explicitly called by the host
- if a handler throws, the current effect call fails
So it is not automatically a fire-and-forget queue with guaranteed independence from failure.
## How multiple effect handlers run
If one point has multiple effect handlers:
- they run in registration order
- handlers from active powers participate
- if one throws, the current effect call stops
## Best-fit scenarios
### observation
For example:
- record who showed up
- record which action happened
### analytics
For example:
- how many times a feature was triggered
- where an error appeared in the flow
### syncing
For example:
- copy state into another store
- write an audit event
## Poor-fit scenarios
### rewriting values
That belongs in `pipeline`.
### blocking the flow
That belongs in `guard`.
### producing one authoritative answer
That belongs in `resolve`.
## One practical recommendation
If your effect is mission-critical and needs retries, durable storage, or guaranteed delivery, it may no longer be a simple effect concern.
In that case, a healthier split is often:
- the effect triggers something
- power `lifecycle` or fuller background runtime owns the reliable processing
## Related scenarios
- [One full message flow](/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [AuditEffectPower scenario](/en/powers-docs/examples/audit-effect-power)
### Resolve
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/resolve/
`resolve` is the easiest extension point to misunderstand.
It may look like just another handler, but its semantic job is not "participate in a flow."
Its job is:
- provide the final answer to a named question
## Separate it from the first three
The first three are closer to:
- `pipeline`: several participants transform a value
- `guard`: several participants inspect a value
- `effect`: several participants trigger side effects
`resolve` is closer to:
- who is the final authority for this answer
## Why there must be exactly one resolver
Because the question it serves is usually not "let several powers each add something."
It is more like:
- what is the final role
- which provider is finally selected
- who is the final route owner
These questions require:
- one definite answer
- one final authority
That is why duplicate registration for the same `resolve` point throws immediately.
## Minimal example
```ts
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>(
"auth.resolve_role",
{ userId: "owner" },
);
```
A power can define it like this:
```ts
resolves: {
"auth.resolve_role": async ({ value }) => {
const body = value as { userId?: unknown };
const userId = String(body.userId || "").trim();
if (!userId) {
throw new Error("userId is required");
}
if (userId === "owner") return "admin" as never;
return "member" as never;
},
}
```
## Failure rules are part of the design
### duplicate registration
It throws.
The system does not allow two final authorities for one point.
### no resolver registered
It throws.
The host explicitly asked a question that must have an answer.
### resolver power is currently disabled
It also throws.
This is not a "skip if nobody participates" point.
It is a "there must be one active final answer owner" point.
## Strong use cases
### final role resolution
For example:
- is the user `admin`, `member`, or `guest`
### final strategy selection
For example:
- which provider should finally be used
- which executor should finally own this mode
### unique ownership decisions
For example:
- which power is responsible for interpreting this object
## When not to use resolve
### several powers each want to contribute a little
That is really `pipeline`.
### you only want to decide whether the flow may continue
That is really `guard`.
### you only want to record that something happened
That is really `effect`.
## One stable test sentence
If your question naturally sounds like:
- "what is the final answer?"
then `resolve` is probably a good fit.
If it sounds more like:
- "should this value be enriched a bit more?"
then it probably is not `resolve`.
## Related scenarios
- [Why role resolution must stay unique](/en/agent-sdk-docs/powers/hooks/examples/role-resolution-flow)
- [RoleResolverPower scenario](/en/powers-docs/examples/role-resolver-power)
### How to Choose
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/choose-the-right-one/
This page answers one question only:
- where should this capability actually live
## Start with the shortest version
If you want to:
- explicitly invoke a named capability, use an `action`
- progressively transform a value, use `pipeline`
- block before the flow continues, use `guard`
- keep the main value unchanged and record or sync something, use `effect`
- get one final authoritative answer, use `resolve`
- own long-lived state, workers, or connections, use power `lifecycle`
## Common questions
### "I want to add default fields to the input"
Use `pipeline`.
### "I want to stop the request immediately if permission is missing"
Use `guard`.
### "I want to write one audit log every time this happens"
Use `effect`.
### "I want to know the user's final role"
Use `resolve`.
### "I want an external caller to trigger a named command"
Use an `action`.
### "I want a long-lived cache, connection pool, or background worker"
Use power `lifecycle`.
## Common bad splits
- using `resolve` for multi-power enrichment
- using `effect` as a hidden reliable job queue
- using `guard` while also rewriting values
## One practical decomposition pattern
If one capability needs all of these:
- an explicit command entry
- runtime-chain extension
- long-lived state
then the clean split is often:
- `action` for explicit invocation
- `pipeline / guard / effect / resolve` for runtime-chain extension
- `lifecycle` for long-lived state
## Related scenarios
- [One full message flow](/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [Common bad vs good designs](/en/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design)
- [Power Runtime Composition](/en/powers-docs/examples/power-runtime-composition)
### One Full Message Flow
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow/
This page stops looking at one point in isolation and instead connects three hook types into one runtime path.
## Scenario
You have one inbound message and want it to go through three steps:
1. enrich it with source and page information
2. check whether it was reviewed
3. record one observation log
## Host code
```ts
const enriched = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", {
text: "Please summarize this page",
});
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", enriched);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", enriched);
```
## The corresponding powers
### MessageAugmentPower
Owns the `pipeline` step:
- add fields to the input
### ReviewGuardPower
Owns the `guard` step:
- decide whether the flow may continue
### AuditEffectPower
Owns the `effect` step:
- record what happened
## Why these should not be merged into one point
Because they answer three different questions:
- `pipeline`: should the value change
- `guard`: may the flow continue
- `effect`: should something be recorded
Once these are mixed into one point, the runtime meaning usually becomes harder to follow.
## Main takeaway
Hooks do not mean "a power decides when it runs."
They mean:
- the host defines the sequence
- powers participate at named points in that sequence
## Related docs
- [Call flow](/en/agent-sdk-docs/powers/hooks/call-flow)
- [Pipeline](/en/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/en/agent-sdk-docs/powers/hooks/guard)
- [Effect](/en/agent-sdk-docs/powers/hooks/effect)
### Why Role Resolution Must Stay Unique
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/examples/role-resolution-flow/
## Scenario
The host wants to answer one question:
- what is this user's final role
So it calls:
```ts
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>(
"auth.resolve_role",
{ userId: "owner" },
);
```
## Why two resolvers would be a problem
If two powers can both answer:
- power A says `admin`
- power B says `member`
then the host has to add another layer of arbitration.
But the whole point of `resolve` is to avoid that extra arbitration layer.
Its design means:
- this point must decide its single authority ahead of time
## That is why the implementation behaves this way
- duplicate registration for the same resolve point: throw
- no resolver registered: throw
- resolver power disabled: throw
All three rules protect the same invariant:
- the final answer must stay unambiguous
## If you really want several participants
Then you probably should not use `resolve` for that part.
A healthier structure is often:
- first use `pipeline` to collect and enrich context
- then let one `resolve` point produce the final answer
## Related docs
- [Resolve](/en/agent-sdk-docs/powers/hooks/resolve)
- [How to choose](/en/agent-sdk-docs/powers/hooks/choose-the-right-one)
### Common Bad vs Good Designs
Source: https://downcity.ai/en/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design/
## Misuse 1
Need:
- add default fields to a message
Bad choice:
- use `guard`
Better choice:
- use `pipeline`
## Misuse 2
Need:
- block the request if permission is missing
Bad choice:
- use `effect`
Better choice:
- use `guard`
## Misuse 3
Need:
- write one audit log after each execution
Bad choice:
- use `pipeline`
Better choice:
- use `effect`
## Misuse 4
Need:
- determine the user's final role
Bad choice:
- use `pipeline`
Better choice:
- use `resolve`
## Misuse 5
Need:
- maintain a long-lived cache and background sync worker
Bad choice:
- put everything into power hooks
Better choice:
- let power `lifecycle` own the long-lived state
- keep hooks focused on chain extension
## Related docs
- [How to choose](/en/agent-sdk-docs/powers/hooks/choose-the-right-one)
- [Power Runtime Composition](/en/powers-docs/examples/power-runtime-composition)
### Power System Text
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-system/
A power can implement:
```ts
system(context) {
return "power-level system text";
}
```
## What it means
`power.system()` expresses:
- this power wants to contribute one system-level text block into runtime prompt assembly
Typical uses:
- provider-specific operating guidance
- capability-specific usage constraints
- telling the agent which installed capability surface is currently available
In the full runtime prompt collection path, power system text is part of the formal runtime system assembly.
The runtime currently:
- iterates over built-in powers
- only reads `system()` from registered powers
- fail-opens if one power system loader throws
## When power.system is a good fit
Use it when the text belongs to:
- one power's operating guidance
- one provider's rules of engagement
- one capability package's runtime instructions
## Built-in powers that use it clearly
- `skill`
- `web`
- `sound`
These are all good examples of capability-description system text.
## Related docs
- [Built-in powers today](/en/agent-sdk-docs/powers/built-in-powers)
- [Custom power](/en/agent-sdk-docs/powers/custom-power)
### Power Availability
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-availability/
Two facts matter:
- whether the Power is registered in City and therefore projected to every Agent;
- whether the current Agent/Workspace satisfies the Power's `availability()` check.
```ts
const powers = city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id });
powers.has("skill");
powers.status("skill");
await powers.availability("skill");
```
A newly added Power is `initializing` until `start` completes, then becomes `ready`. A failed start
produces `error`. An ordinary Action business failure does not change the Power's global state.
`availability()` only answers whether the Power can work in the current Context—for example whether
a provider, dependency, model, or credential is ready. It does not bind Powers to Agents; all City
Powers are always visible to all City Agents.
### Power HTTP Injection
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-http/
A power can declare:
```ts
http: {
runtime: {
auth_policies: [...],
register({ app, get_context, power_name }) {
// register routes
},
},
}
```
## What problem this solves
It lets a power declare:
- which runtime HTTP routes it owns
- which auth policies those routes require
and leaves server assembly to the runtime layer.
## `auth_policies`
This describes:
- how the power-owned route group should be protected
The key idea is:
- the power declares its own security needs
- runtime and console consume that declaration
## `register`
This is where the power adds its routes to the runtime Hono app.
The power does not need to start a server. It only contributes route registration.
## When power HTTP is a good fit
Use it when:
- you want a dedicated runtime HTTP endpoint
- the endpoint belongs to one power capability surface
- you want auth, availability, and control-plane identity to stay aligned with that power
## When it is not the right fit
If you only need:
- normal session execution
- standard SDK session routes
then the built-in session HTTP layer should remain your first choice instead of building a parallel power HTTP interface.
## Related docs
- [Built-in powers today](/en/agent-sdk-docs/powers/built-in-powers)
- [Custom power](/en/agent-sdk-docs/powers/custom-power)
### Custom Power
Source: https://downcity.ai/en/agent-sdk-docs/powers/custom-power/
Custom Power contracts come from `@downcity/city/power`. City owns one Power instance per Power ID
and automatically exposes every registered Power to every Agent.
```ts
import {
Power,
create_action,
} from "@downcity/city/power";
class NotesPower extends Power {
readonly name = "notes";
readonly title = "Notes Helper";
readonly description = "Adds note-related actions.";
readonly actions = {
status: create_action({
execute: async ({ context }) => ({
success: true,
data: {
workspace_path: context.workspace.path,
storage_path: context.storage.path,
session_id: context.session?.id ?? null,
},
}),
}),
};
}
const city = new City({ powers: [new NotesPower()], workspaces: [workspace] });
city.agents.add(agent);
```
Implement only the Actions, Hooks, Resolves, System, Availability, HTTP, or Lifecycle behavior the
domain actually needs. Use `initialize/dispose` for Power-owned resources and derive any internal
resource key from real domain ownership, not Workspace entry. Do not hide long-lived resources inside Hooks.
See [Power Development](/en/agent-sdk-docs/powers/power-development) for the complete module,
manifest, Context, and testing model.
### Agent Powers Docs Moved
Source: https://downcity.ai/en/agent-sdk-docs/powers/built-in-powers/
Concrete built-in power guides now live in [Agent Powers Docs](/en/powers-docs).
Start with:
- [Agent Powers Docs Home](/en/powers-docs)
- [Built-ins Overview](/en/powers-docs/builtins/overview)
- [Examples](/en/powers-docs/examples)
If you want power concepts instead of concrete power manuals, continue with [Power Overview](/en/agent-sdk-docs/powers/overview).
### Power Development
Source: https://downcity.ai/en/agent-sdk-docs/powers/power-development/
Power author APIs live in `@downcity/city/power`. A Power is a long-lived instance owned by City;
do not create one instance per Agent.
## Execution instance
```ts
import { Power, create_action } from "@downcity/city/power";
import { z } from "zod";
class ExamplePower extends Power {
readonly name = "example";
readonly title = "Example";
readonly description = "An example Power.";
constructor(private readonly client: ExampleClient) {
super();
}
readonly actions = {
status: create_action({
description: "Return the current scope.",
input_schema: { zod: z.object({}) },
execute: async ({ context }) => ({
success: true,
data: {
agent_id: context.agent.id,
workspace_id: context.workspace.id,
session_id: context.session?.id ?? null,
tool_call_id: context.call.tool_call_id ?? null,
},
}),
}),
};
}
```
Use `context.workspace.files` for user Workspace files and `context.storage.files` for private Power
state. `context.session` and `context.agent.sessions` are directly callable restricted handles; a
Power does not need to resolve objects from IDs alone.
## Unified City module
```ts
export default new ExamplePower();
```
An SDK host places the instance in City and adds Agents:
```ts
const city = new City({ powers: [new ExamplePower()], workspaces: [workspace] });
city.agents.add(agent);
```
## Lifecycle
`initialize/dispose` belong to City's unique Power instance. Agent and Workspace are dynamic call
context, not lifecycle boundaries. Key long-lived resources by their actual domain owner and close
all of them in `dispose`. Every asynchronous Action should propagate `execution.abort_signal`.
## Inject domain callbacks when constructing a Power
A Power reports its own committed business changes. Memory tools, host management, direct Provider operations, and background Profile optimization share the same commit points:
```ts
const memory_power = new MemoryPower({
platform_root_path,
on_changed: (event) => {
if (event.kind === "knowledge") {
application.reload_memory(event.memory_id);
} else {
application.reload_profile(event.target, event.agent_id);
}
},
});
```
MemoryChange includes `operation: written/deleted`. Knowledge events carry a logical memory_id; Profile events carry target and agent_id (null for User). No content, file paths, or Action routing metadata is included. Each committed evidence record or projection reports separately. Later failure does not undo an earlier committed change. Missing-record deletion, duplicate Profile append, and edits with unchanged Profile content emit nothing.
Callbacks run after persistence succeeds. Synchronous exceptions and Promise rejections are isolated without changing the business result, and callback Promises are not awaited. Applications own refresh batching, IPC, and UI behavior. Task's on_changed reports definition and execution-state changes; Session retains its own on_created/on_removed lifecycle protocol.
There is no general action_hooks, PowerOptions, or SDK invalidate interface. Existing pipeline/guard/effect hooks control session checkpoints, and notifications.publish creates user-facing notifications. Domain callbacks are local to the instance without historical replay or reliable retry; external file changes do not automatically emit events. Other Powers require their own domain-specific commit points; Memory support does not imply that every Power already has live change callbacks.
## Installable package
```text
example-power/
├── power.json
├── package.json
├── README.md
├── dist/main.js
└── dist/renderer.js
```
```json
{
"schema_version": 1,
"id": "example",
"version": "1.0.0",
"description": "A small example Power.",
"readme": "./README.md",
"main": "./dist/main.js",
"renderer": {
"entry": "./dist/renderer.js",
"sidebar": true,
"mainview": true,
"config": false
}
}
```
`main` default-exports a Power instance or `{ power, main }`; there is no separate `agent` entry. A Renderer
communicates only through the host action gateway and cannot access Node, Electron, Agent objects, or
Power Config files directly.
To contribute an Agent Profile tab, declare `agent_config: { id, label, component }` in `define_power_renderer()`. The component receives `{ agent_id, power, ui }`, with types exported from `@downcity/city/power/react`. Enabled Powers contribute their tabs automatically; the host namespaces each tab ID by Power. No `power.json` capability flag is needed. Use the Power business gateway for Agent-specific operations and validate their inputs in the owning Power. See [Agent Profile tab development](/en/docs/power/custom#agent-profile-tab) for the exact contract and an example.
## Testing focus
- Multiple Agents using one City Power initialize one instance and read the same configuration.
- Repeated calls receive fresh Context objects with the correct Agent and Workspace.
- `dispose` waits for the final execution lease.
- Concurrent main calls activate once, and shutdown fully deactivates it.
- Action input, JSON results, and cancellation signals remain valid.
### RemoteAgent Quickstart
Source: https://downcity.ai/en/agent-sdk-docs/remote-agent/quickstart/
`RemoteAgent` is the remote session client.
It currently supports:
- `http://...`
- `https://...`
- `rpc://...`
## Expose a reachable server-side endpoint first
### Option 1: publish HTTP through Downcity
```bash
downcity on
```
### Option 2: start a City RPC or HTTP server
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({ id: "project", path });
const agent = new Agent({ id, model });
const city = new City({ workspaces: [workspace] });
city.agents.add(agent);
await city.rpc({ host: "127.0.0.1", port: 15314 });
```
## Connect from the client
### Connect over HTTP
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "http://127.0.0.1:5314/agents/assistant/workspaces/project",
});
const session = await agent.sessions.create();
const turn = await session.prompt({
query: "Summarize the current repository structure",
});
const result = await turn.finished;
```
### Connect over RPC
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "rpc://127.0.0.1:15314/assistant/project",
});
```
## One important limitation
Remote Sessions do not expose model configuration:
```ts
// model configuration is not available on RemoteAgentSession
```
The server host owns the local Agent and its runtime model instance.
If you are in pure SDK mode, configure the model on the server-side local `Agent` or local session.
If you are in Downcity integration mode, the model usually comes from:
- Agent config `execution.modelId`
- the connected `Federation AIService`
## Good fit for
- another process already owns the local Agent
- you only want to consume agent capability through HTTP or RPC
- you want to separate the caller from the executor
### Remote Session API
Source: https://downcity.ai/en/agent-sdk-docs/remote-agent/remote-session-apis/
Remote Sessions provide `get_info()`, `prompt()`, `stop()`, `subscribe()`, `messages()`,
`system()`, `interactions()`, `respond()`, `status()`, `set({ security })`, and `fork()`.
```ts
const session = await agent.sessions.create();
const unsubscribe = session.subscribe(apply_mutation);
const turn = await session.prompt({ query: "Continue" });
await turn.finished;
const page = await session.messages();
const status = await session.status();
await session.set({
security: { approval_mode: "always-allow" },
});
unsubscribe();
```
The subscription emits `message | part | delta | turn | file_diff | session | warning`. User participation is a pending Interaction on its Tool Part while that Tool is `waiting-user`; a failed model call emits `warning / model_request`. Submit structured user responses with `session.respond(...)`.
A prompt requires a trusted host-bound `actor`. The same actor can append input to its active Turn; another actor receives `session_busy`. Read `status.occupied_by` to inspect ownership. `stop(expected_turn_id)` waits for stopping and rejects if the target Turn has changed.
Remote clients can set serializable security policy but do not select models through the Agent SDK.
The server host owns model IDs, defaults, recovery, and runtime instance injection.
### Transport Behavior
Source: https://downcity.ai/en/agent-sdk-docs/remote-agent/transport-behavior/
RemoteAgent supports two transport shapes:
- `http://...` / `https://...`: calls the SDK routes mounted by the Downcity City HTTP gateway
- `rpc://...`: connects directly to the City RPC server
## Regular requests
For example:
- create session
- list sessions
- `prompt`
- `history`
- `fork`
Those use normal JSON request-response exchanges.
## Event subscription requests
Over HTTP, `events` uses an NDJSON-style long-lived response.
Over RPC, the same session events are delivered through the RPC connection.
For HTTP callers, the client receives JSON events one line at a time.
Each line can be treated as an independent session event.
## What this means for callers
- if you only care about one turn result, wait for `turn.finished`
- if you are building a real-time UI, use `subscribe()`
If you want to show tool activity, progressive output, or real-time chat UI, `subscribe()` is usually the right fit.
### Current Limitations
Source: https://downcity.ai/en/agent-sdk-docs/remote-agent/limitations/
If the server exposes neither:
- HTTP through the Downcity Agent gateway
nor:
- RPC through `city.rpc({ ... })` from the server-side `City`
then `RemoteAgent` cannot connect.
### Example: Client / Server Mode
Source: https://downcity.ai/en/agent-sdk-docs/remote-agent/example-client-server/
This mode usually looks like:
- the server process owns the local `Agent`
- the server exposes HTTP or RPC through the containing `City` with `city.http()` / `city.rpc()`
- the client process creates `RemoteAgent`
### Session Overview
Source: https://downcity.ai/en/agent-sdk-docs/sessions/overview/
`Agent` owns identity, model, custom tools, and Powers. `AgentSessions` owns the Session objects, while
each Session may receive a Workspace for project tools and environment. A `Session` owns one continuous
conversation, its history, streaming output, and tool approvals.
A Session is one persisted, linearly ordered Message sequence. It can be reopened with `get()` after a process restart, and local `Agent` and `RemoteAgent` share nearly the same calling model.
## The essential flow
```text
create / get
-> load an initial messages() snapshot
-> subscribe() to live changes
-> prompt() returns a turn handle
-> handle delta, part, and approval
-> await turn.finished
-> stop(), fork(), or archive when needed
```
Subscribe before calling `prompt()`. A subscription only receives future Mutations; it never replays output that happened before it was established.
## A complete local example
```ts
const session = await agent.sessions.create({
workspace,
});
await session.set({ model });
const initial_page = await session.messages();
render_messages(initial_page.items);
const unsubscribe = session.subscribe((mutation) => {
apply_mutation_to_ui(mutation);
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
const turn = await session.prompt({ query: "Analyze this repository's Session design" });
const result = await turn.finished;
set_turn_status(turn.id, result.success ? "completed" : "failed");
unsubscribe();
```
`apply_mutation_to_ui()` should merge snapshots by `message_id + revision` and append each `delta` only to its matching text or Tool input. See [Live subscriptions](/en/agent-sdk-docs/sessions/subscribe) and [Reconnect and sync](/en/agent-sdk-docs/sessions/reconnect-and-sync).
On failure, an Error Part in the closed canonical Agent Message carries the user-visible error. Use `turn.finished.error` for control flow, logging, or interfaces that do not render Messages; a chat UI that already renders Session Messages must not append it as a second error.
## The objects inside a Session
| Object | Purpose | When to use it |
| --- | --- | --- |
| `SessionMessage` | The only persisted history model | Initial load, reconnect, history browsing |
| `SessionMutation` | Live protocol for changes after subscription | Streaming UI, tool state, title updates |
| `TurnHandle` | Execution handle for an accepted input | Waiting for the final result, checking a stopped turn |
| `SessionInteractionRequest` | A request waiting for user input | Approval, questions, background processing |
Do not treat Messages and Mutations as interchangeable. Messages are recoverable state snapshots; Mutations deliver state changes to a live client.
## Session runtime model
A Session is the ordering and persistence boundary for its inputs. Prompts, model changes,
environment updates, power updates, approval-mode changes, and compaction enter one
server-side FIFO. They execute in submission order before a Turn starts or at a Model Step
checkpoint. Remote clients call Session APIs and do not duplicate this scheduling logic.
```mermaid
flowchart TB
App["SDK / Remote Client"] --> Session["Session
API and lifecycle boundary"]
subgraph Runtime["Authoritative Session Runtime"]
Session --> Queue["Ordered input queue
SessionCommand FIFO"]
Queue --> Loop["SessionLoop
sole consumer and Turn scheduler"]
Loop --> Executor["Executor
Model / Tool Step Loop"]
Session --> Security["Security Policy
configured / effective"]
Security --> Executor
Session --> Status["status() read projection"]
Loop -.-> Status
Security -.-> Status
Session --> Interactions["Interactions
Approval / Question waiter"]
Session --> Messages["SessionMessages
Canonical state"]
Executor --> Messages
Executor --> Interactions
Loop -->|"Command completion"| Messages
Messages --> Store["Message Store"]
Messages --> Events["Session Mutation"]
end
Session -->|"respond() resolves the waiter directly"| Interactions
Session -->|"stop() interrupts immediately"| Turn
Events --> App
```
Only operations that require FIFO ordering and Step checkpoints enter the queue. `respond()`
must resume the current Interaction immediately, while `stop()` must interrupt the active Turn,
so neither operation is queued.
A Command may declare an Action completion to persist after successful execution. The Session
applies the domain change first and then persists a canonical Agent Message containing the Action Part.
Its Mutation is published only after that Message commits. A completion-persistence failure does not roll back an
already applied configuration and does not publish a success event that has no persisted state.
## Local versus remote
Both expose `prompt()`, `stop()`, `subscribe()`, `messages()`, `interactions()`,
`respond()`, `status()`, `set({ security })`, and `fork()`. Local Sessions additionally provide
`set({ model })`, `rename()`, `syncshot()`, and `snapshot()`; remote model and system policy belongs to the server host.
Use `await session.rename(title)` to persist a local Session's visible title. The method returns the
normalized title and publishes the canonical Session title Mutation after metadata commits. Clients
should update their navigation projection from that Mutation instead of storing a second title.
| Context | Configure a model |
| --- | --- |
| Local `AgentSession` | `session.set({ model })`; accepts a `ModelClient` instance |
| `RemoteAgentSession` | `session.set({ security })`; does not accept model instances |
| Downcity Agent project | CLI resolves `execution.modelId` and passes a `ModelClient` instance to the Agent |
Remote clients do not participate in model selection. IDs, defaults, and recovery belong to the server host.
## API map
- Manage local Sessions: `agent.sessions.create/get/list/archive/archived/clean_archive/remove/clear_messages()`
- Input and completion: `session.prompt()` and `turn.finished`
- Read and stream: `session.messages()`, `session.get_info()`, and `session.subscribe()`
- Rename a local Session: `session.rename(title)`
- Control execution: `session.stop()`, `session.set()`, and `session.fork()`
- Refresh and persist system: [`session.syncshot()`](/en/agent-sdk-docs/sessions/syncshot), [`session.snapshot()`](/en/agent-sdk-docs/sessions/snapshot)
- Tool approval: `session.interactions()`, `session.respond()`, `session.set({ security })`, and `session.status()`
- Inspect context: `session.system()` and [metadata and storage](/en/agent-sdk-docs/sessions/metadata-and-storage)
## Next
Start with [Managing Sessions](/en/agent-sdk-docs/sessions/session-management), then read [Prompts and Turns](/en/agent-sdk-docs/sessions/prompt-and-turns) and [Messages](/en/agent-sdk-docs/sessions/messages).
### Session Origin and Types
Source: https://downcity.ai/en/agent-sdk-docs/sessions/origin-and-types/
Every Session has an origin, made of two fields: `category` and `type`. They answer different questions, and you decide both.
`category` is the classification, with exactly two values: `chat` and `work`. It decides which physical partition the session lives in, and whether it can dispatch work. `type` is the role, describing what the session actually is. The category answers "which layer", the role answers "what it is".
## Which category to use
Ask one question: is this session for talking, or for doing work?
| What you are building | Category | Type |
| --- | --- | --- |
| A conversation with the user (your product's main surface) | `chat` | `agent` |
| An external chat channel (Telegram, Feishu, ...) | `chat` | `channel` |
| A member session inside a group chat | `chat` | `group` |
| Question and answer inside a browser extension | `chat` | `chrome` |
| Executing one specific piece of work | `work` | `work` |
| One execution triggered by a scheduled task | `work` | `task` |
Omitting `origin` defaults to `{ category: "work", type: "work" }`, an ordinary work session.
## What the category controls
**Physical partition.** Sessions live under `sessions///`. There are only two categories, so an Agent's session directories are at most two levels wide. The same `session_id` can exist under `chat` and under `work` at the same time without conflict — identity is the category plus the ID.
**Whether it can dispatch work.** Sessions in `chat` can start, check, reply to and stop other sessions, and can archive them. Sessions in `work` focus on the job they were handed and do not dispatch further. Only `work` sessions can be archived.
The role does not shape the partition. It only affects specific behavior branches, such as picking a platform prompt for external chats or validating group membership.
## Open a conversation session
```ts
const session = await agent.sessions.create({
origin: { category: "chat", type: "agent" },
workspace,
});
```
## Dispatch a piece of work
```ts
const work = await agent.sessions.create({
origin: { category: "work", type: "work" },
workspace,
});
const turn = await work.prompt({ query: "Turn this report into a table" });
await turn.finished;
```
## List sessions by category and role
The first dimension of `list` is the category. Add the role when the category alone is not specific enough.
```ts
// Every session in one category
const conversations = await agent.sessions.list({ category: "chat" });
// Only the Agent's own main session
const main = await agent.sessions.list({
category: "chat",
origin_type: "agent",
});
```
Restoring an existing session also takes the category:
```ts
const session = await agent.sessions.get(session_id, "chat", { workspace });
```
## Custom roles
The six known roles cover the common cases. If your product has its own notion of a session, you can declare a custom role, but you must also give the category. Otherwise creation fails outright:
```ts
const session = await agent.sessions.create({
origin: {
category: "work",
type: "automation/daily",
schedule_id: "daily-report",
},
});
```
The category has to be `chat` or `work`, because it is the only field that decides the partition and the dispatch ability. The role name can be any non-empty string. Fields beyond those two are persisted with the origin and interpreted by you.
This constraint is deliberate: adding a role costs nothing, while adding a partition has to be stated explicitly. A typo in a string can never silently drop your session into a different directory.
### Agent main session
Source: https://downcity.ai/en/agent-sdk-docs/sessions/agent-chat/
An Agent's main session has `origin.type = "agent"` and uses the Agent ID as its session ID. It keeps the ongoing conversation and can dispatch, inspect, reply to, and stop work sessions.
## Private directory in Desktop
Desktop gives each Agent a fixed directory:
```text
~/.downcity/agents//main/
```
This is the main session's own Workspace, providing files, Shell, and Power execution. It is not a user project and does not appear in the Works project list. Switching projects or reopening the Agent does not change this directory. Conversation history remains under `sessions/agent//`, separate from files in `main`.
Project tasks run in `work` sessions. Dispatch must explicitly select a project Workspace; work sessions do not inherit the main session's private directory.
## SDK access and restoration
The SDK does not choose the directory location. The host creates and owns the Workspace instances:
```ts
const agent = new Agent({
id: "coder",
model,
workspace: main_workspace,
resolve_work_workspace: async (workspace_id) => {
const workspace = project_workspaces.get(workspace_id);
if (!workspace) throw new Error("Project Workspace not found");
return workspace;
},
});
await agent.sessions.self({ workspace: main_workspace });
// self uses its Workspace argument only for creation.
// Restore the instance explicitly when opening an existing session.
const session = await agent.sessions.get(agent.id, "agent", {
workspace: main_workspace,
});
const turn = await session.prompt({ query: "Inspect project CI and dispatch a repair task." });
await turn.finished;
```
`resolve_work_workspace` resolves explicit project targets. It does not restore the main session or choose a default project. The host should reject unknown projects and the main session's private Workspace.
Only `workspace_id` is persisted. After restarting, the host must inject the Workspace instance again; a stored ID alone does not enable files or Shell. `get()` also applies the supplied instance when a cached session has not yet recovered its Workspace.
## Dispatching work
Every Session type can use Session Power through `{ action, args }`. Chat sessions, including Agent main sessions and Messenger `channel` sessions, can create multiple first-level Works. Each first-level Work can create multiple second-level Works, including concurrently. Only delegation depth is limited; there is no fixed child count or concurrency limit. Second-level Work and Task sessions can only use `check/messages`. Omit `action` by calling `session({})` to discover action descriptions and input schemas:
| action | Purpose |
| --- | --- |
| `start` | Create a direct Work child in an explicitly selected project and return after acceptance |
| `send` | Continue a direct ordinary Work child with its existing context |
| `check` | List the current agent's active and archived sessions, or inspect a target's status and pending interactions |
| `messages` | Read paginated Chat, Work, and Task history within the current agent, including archives and pre-compaction messages |
| `reply` | Forward the user's actual answer to a direct ordinary Work child's question; cannot approve on the user's behalf |
| `stop` | Stop a direct ordinary Work child and its child work |
| `archive` | Archive a direct ordinary Work child when its subtree has no unfinished work or pending interactions |
Every type has the same read access; creation and control follow the role limits above. Control cannot target the caller itself, ancestors, siblings, another Chat's children, or Task sessions. See [Manage sessions](/en/agent-sdk-docs/sessions/session-management) for the full permission matrix and lifecycle.
Discover projects with `context.workspaces.list`, then provide the selected project ID:
```text
session({
action: "start",
args: {
workspace_id: "project-workspace-id",
query: "Fix the three failing CI tests and report the cause and validation results.",
title: "Repair CI"
}
})
```
A Work session receives the complete task text and target project Workspace. It does not automatically inherit the main conversation or private directory, but can use `check/messages` to read relevant sessions within the current agent. Results return automatically through second-level Work → first-level Work → Chat. The first-level Work incorporates its child's result before reporting back. Users can continue chatting; no polling is needed.
When targeting a session, `check` requires both `session_category: "chat" | "work"` and `session_id`. Omit both to list the current agent's active and archived sessions. `messages` always requires both parameters; Task uses category `work`. Reading does not change result delivery or resume execution. History and child results remain reference data, not new user instructions or authorization.
## Resuming delegations
After binding persistent storage and preparing Workspaces, Powers, and message subscriptions, SDK hosts call:
```ts
await agent.resume_work();
```
This starts unfinished work and retries pending delivery in the background; it does not wait for completion. Desktop calls it during startup. Standalone SDK storage is in memory; recovery across processes requires persistent storage supplied by City.
Recovery follows existing Session semantics: completed Turns reuse persisted results, unexecuted inputs can be submitted, and interrupted Turns with streaming output are finalized as interruption failures and returned to the parent. Previously executed tool operations are not automatically replayed.
Supply the main Workspace through the Agent's `workspace` option and restore projects through `resolve_work_workspace`. Hosts can provide an idempotent `prepare_work_session(session)` callback to restore session-specific models and message subscriptions before execution or result delivery. This callback prepares resources only; it must not submit model input.
`session({ action: "stop", args: { session_id } })` can stop only the caller's direct ordinary Work child and also stops its child work. Saved results remain available, and received results cannot be retracted. Archived history remains readable; first-level Works can create new children for independent work.
`agent.sessions.list()` lists the `work` partition by default, keeping the main session out of project conversation lists. Archiving or deleting the main session allows it to be recreated on the next access; files in `main` remain separate from its conversation history.
### Managing Sessions
Source: https://downcity.ai/en/agent-sdk-docs/sessions/session-management/
`agent.sessions` is the local collection entry point. A session may choose a Workspace as its initial directory, and may switch it later.
## Turn ownership and request callbacks
`session.prompt()` accepts an optional `actor`; when omitted it defaults to the local user `{ kind: "user", user_id: "local" }`, which suits input a host forwards directly. Because it decides Turn ownership and `request_id` belonging, automatic execution entry points must declare their real identity. The first input claims an idle Turn. Further inputs from the same actor enter its internal input queue; another actor receives `SessionBusyError` with code `session_busy`, without enqueueing input. The `exclusive` and `send` modes have been removed.
Use `create_session_prompt_actor({ agent_id, workspace_id, session_id, origin_type })` for a source Session; the source Turn is not part of actor equality. Trusted Task and Group hosts use `create_runtime_prompt_actor({ namespace, object_id })`, and a custom user identity uses `create_user_prompt_actor(user_id)`. SDK and remote SDK clients must supply a truthful identity; do not expose this field as a model-controlled argument.
`session.status()` includes `occupied_by`. User input is also rejected while another Session owns the Turn. To take over, first `await session.stop(expected_turn_id)`, then submit user input. Stop rejects if the Turn changed. Another actor can still claim the Session between these operations; the new submission then receives busy.
The same actor can retry a `request_id` to reuse its original result; another actor cannot reuse that key. Ownership protects the City-owned Session instance, not independent Cities sharing storage.
Register callbacks on the target Agent, then select a callback when submitting work:
```ts
const agent = new Agent({
id: "reviewer",
model,
callbacks: {
review_completed: async ({ result, data, delivery_id }, context) => {
await review_outbox.enqueue({
idempotency_key: delivery_id,
result,
data,
});
},
},
});
const work_session = await agent.sessions.create({ workspace });
const turn = await work_session.prompt({
request_id: "review-123",
query: "Review payment code",
callback: { name: "review_completed", data: { review_id: "123" } },
});
```
`review_outbox` is application-provided. A Tool can return once it receives the Turn handle. The runtime persists the callback configuration and invokes it after execution; delivery failures retry the callback without rerunning the model. Callbacks receive `request_id`, `result`, `data`, and a stable `delivery_id` for idempotent consumption. Requests merged into one Turn acknowledge independently. A callback requires a `request_id`, a name already registered on the target Agent, and JSON parameters. Register the same names on startup and call `agent.resume_work()` to restore pending delivery.
Stopping a Turn also delivers its callback with `success: false` and `error: "Turn stopped"`. Delivery may happen after the user starts another Turn, but always contains the original result. Later Turns do not inherit that callback. A callback that submits another prompt must follow normal actor ownership rules.
Registered delegation results only enter the original parent Turn. If that Turn has ended, the result remains saved in the delegation record and does not automatically start or enter a later Turn.
## Create and open
```ts
const created = await agent.sessions.create({
workspace,
});
const task_session = await agent.sessions.create({
workspace,
origin: { type: "task", task_id: "daily-report" },
});
const existing = await agent.sessions.get("session-id-from-list", "work");
const existing_task = await agent.sessions.get(task_session.id, "task");
```
`create()` always generates the Session ID internally. To reopen an existing Session, obtain its ID from
`list()` or another persisted reference and pass it to `get(session_id, origin_type = "work")`.
`origin.type` may be any non-empty string and may carry additional JSON metadata. `get()` reads only the
requested partition and never scans other origins.
A Workspace is the session's current directory, not its identity. It is a persisted configuration field
on the session, sharing the `set()` handle with `model`:
```ts
const session = await agent.sessions.create({ workspace: repo_a });
await session.set({ workspace: repo_b }); // move to another repo with history intact
await session.set({ workspace: null }); // leave the project context
```
Only `workspace_id` is written to disk; the host resolves and injects the instance through
`get(..., { workspace })`. Passing a Workspace to `get()` different from the persisted one is
also not an error; it becomes the new current directory:
```ts
const reopened = await agent.sessions.get(session.id, "work", { workspace: repo_c });
```
Switching does not migrate existing messages, attachments, or tool results; those belong to the
directory they happened in. A running turn keeps the previous directory, and the change applies from
the next step checkpoint. A session without a Workspace is valid: it has no file tools and no shell,
while plain model conversation still works.
## Manage owned sessions and delegate to other agents
City registers Session Power to manage the current agent's own sessions. No session action accepts `agent_id`, and other agents' sessions cannot be accessed. Every Session type, including Task and second-level Work, can use `check` and `messages` to read all Chat, Work, and Task sessions owned by the current agent, including archived history. Standalone Agent SDK hosts provide their own collaboration entry points. Select projects with `context.workspaces.list`.
| Caller | `start` | `send` / `reply` / `stop` / `archive` | `check` / `messages` |
| --- | --- | --- | --- |
| Chat | Create multiple first-level Works | Only its own direct ordinary Work children | All current-agent sessions, including archives |
| Work (created by Chat or directly by the user) | Create multiple Subworks, including concurrently | Only its direct ordinary Work children | Same as above |
| Second-level Work, Task | Forbidden | Forbidden | Same as above |
| Other custom types | Forbidden by default | Forbidden by default | Same as above |
First-level Works can create multiple independent children or use `send` to continue existing children. There is no fixed child count or concurrency limit; execution remains subject to queues and runtime resources. Delegation depth is limited to 2. Archived sessions cannot execute again. Reading is independent of depth and does not grant control permission.
```js
session({
action: "start",
args: {
workspace_id: "payment-service",
query: "Review the payment flow and report problems and improvements",
title: "Payment review"
}
})
```
`start` also accepts an optional `model_id`: a configured model identifier including its provider identity. Omitting it uses the current agent's default model. Unavailable models produce explicit errors.
`start` returns an acceptance receipt, not completed work. Results return automatically through each parent: second-level Work → first-level Work → Chat. The first-level Work incorporates its child's result before reporting to Chat. No polling is needed.
Continue that work with the same identifiers:
```js
session({
action: "send",
args: {
session_id: "returned session_id",
query: "Also check refund idempotency"
}
})
```
`send` returns a submission receipt immediately and retains the target's conversation context without creating a new session. Completed or stopped direct Work children can continue; archived sessions cannot. Each execution result returns automatically to its calling session.
`send/reply/stop/archive` can control only ordinary Work sessions created directly by the caller within the current agent. They cannot control the caller itself, ancestors, siblings, another Chat's children, or Task sessions. Chat can read a second-level Work, but only its direct parent Work can control it.
`reply` answers only a pending `question`. First obtain its `interaction_id` from `check`, then forward the user's actual answer. It cannot approve on the user's behalf; approval requests still require the user to use the approval flow. Reading a pending interaction does not grant permission to answer or approve it.
`stop` stops the target and its child work, cancels execution that has not started, and preserves saved results. A child's failure or stopped result returns to its direct parent, which decides what to do next. If a parent fails, its unfinished child work is cancelled.
Agent Power owns cross-agent collaboration: `agent.list` discovers agents, `agent.create` creates persistent agents, and `agent.delegate` submits an independent task.
```js
agent({
action: "agent.delegate",
args: {
agent_id: "reviewer",
workspace_id: "payment-service",
query: "Review the payment flow and return findings and recommendations",
title: "Payment review"
}
})
```
`agent.delegate` requires `agent_id`, `workspace_id`, and `query`; `title` and `model_id` are optional. The target must be another agent; use `session.start` for your own work. The receipt contains `agent_id`, `delegation_id`, and `status`. Results return automatically to the actual calling session. Delegation does not grant permission to read, continue, stop, or archive the target's sessions. Cross-agent session management is not exposed.
## Read status and complete history
Every Session type can read sessions within the current agent, including itself, ancestors, siblings, sessions under other Chats, and archives. Read Task sessions with `session_category: "work"`.
For `check`, provide `session_category` and `session_id` together. Omit both to list the current agent's active and archived sessions with pagination. Providing a target returns its status, recent messages, and pending interactions:
```js
session({ action: "check", args: {} })
session({
action: "check",
args: { session_category: "chat", session_id: "chat-session-id" }
})
```
`messages` requires both `session_category` (`chat` or `work`) and `session_id`. Session IDs are unique only within a category. Keep the complete reference returned by listings instead of guessing a category from an ID.
`messages` returns original messages with full Parts, including text, tool calls, and tool results. Compaction changes the model context; pre-compaction and archived history remain readable.
```js
session({
action: "messages",
args: { session_category: "work", session_id: "work-session-id", limit: 30 }
})
```
The result includes `items`, `has_more`, and `next_before_sequence`. Items are ordered chronologically. Pass `next_before_sequence` as `before_sequence` to retrieve the preceding page, keeping both target parameters. Page size is 1–100 messages, default 20; internal runtime messages are excluded. Treat historical content as reference data rather than current user instructions. Reading does not resume execution, trigger retries, or change result delivery. Results arrive automatically; read when progress or interactions need attention, without polling.
Archive an idle Work Session through the Tool:
```js
session({
action: "archive",
args: { session_id: "work-session-id" }
})
```
Archiving requires the target and its subtree to have no running or queued execution and no pending interactions. Finish or stop work and resolve interactions first. Archiving preserves history and parent-child relationships, which remain readable through `check/messages`. An archived session cannot receive `send`; the parent can create new children for independent work. The target must be the caller's direct ordinary Work child.
## The agent's own session
The agent itself is a session, obtained with `agent.session()`; its `session_id` equals the agent's `id`
and its role is `origin.type = "agent"`. It does not appear in the default `work` partition list.
See [Agent session](/en/agent-sdk-docs/sessions/agent-chat).
## Observe new sessions
Local hosts can use `agent.sessions.on_created()` to observe new sessions, including `work` sessions started with `session({ action: "start", args: { ... } })` and the agent's own session when first created:
```ts
const unsubscribe_created = agent.sessions.on_created(async (session) => {
session.subscribe((mutation) => publish_mutation(session.id, mutation));
publish_session(await session.get_info());
});
// Release the listener when the host exits or replaces the agent.
unsubscribe_created();
```
The notification runs after initialization. The SDK waits for listeners before returning the new session, so hosts can subscribe to messages and runtime changes before the first `prompt()`. Restoring an existing session does not emit it. Listener failures are logged without undoing the persisted session. This is an in-process notification; load the initial directory with `list()`.
Desktop adds new `work` sessions to their Workspace in Works and receives subsequent title, message, and execution updates.
## Observe archive and deletion
Local hosts can subscribe to committed archive and deletion operations with `agent.sessions.on_removed()`, including archives requested through Session Power:
```ts
const unsubscribe_removed = agent.sessions.on_removed((event) => {
remove_session_projection(event.session_id, event.origin_type);
});
// Release subscriptions when replacing the Agent or shutting down the host.
unsubscribe_removed();
```
`event.type` is `archived` or `deleted`. Identify a session by its owning Agent, `origin_type`, and `session_id`. `workspace_id` is provided only when a runtime context is available; because sessions can change workspaces, clear every projection for the stable identity. Failed operations and deletion of a missing session emit no event. Listener errors are logged without rolling back the committed operation. The SDK waits for listeners before returning. Events are local to this process, with no cross-process broadcast or historical replay.
Desktop forwards these events to its windows over IPC to remove active entries, clear message and runtime caches, and update the current view and navigation history. Tool and Desktop operations therefore use the same synchronization path.
## List and search
```ts
const page = await agent.sessions.list({
origin_type: "work",
limit: 30,
query: "repo",
});
for (const item of page.items) {
render_session_row(item);
}
if (page.has_more) {
const next_page = await agent.sessions.list({
limit: 30,
cursor: page.next_cursor,
query: "repo",
origin_type: "work",
});
}
```
`list()` defaults to the `work` partition. Pass `origin_type` to list another partition. List items are lightweight summaries, not complete Messages. Common fields are `session_id`, `origin`, `title`, `preview_text`, `message_count`, `model_label`, `updated_at`, and `executing`. `cursor` is opaque: return it to the SDK unchanged.
`query` is a lightweight contains filter for a session picker. It is not a full-text search language.
## Archive and clean up
```ts
// A local Session delegates archival to its owning collection.
await session.archive();
// The collection entry also accepts an explicit ID and origin.
await agent.sessions.archive({ id: "repo-analysis", origin_type: "task" });
const archived_page = await agent.sessions.archived({ origin_type: "task", limit: 30 });
const removed = await agent.sessions.clean_archive();
console.log(removed.removed_session_ids);
```
Archiving removes a Session and all descendant Work Sessions owned by the same Agent from the active list, including descendants in other Workspaces. History and parent-child relationships are preserved. Children are archived before their parent, and each committed node emits `on_removed`. If any node has running or queued work, the entire operation is rejected before storage changes; wait for its Turn or call `stop()` first. A storage failure leaves the parent active; children already archived are not rolled back. Retry to archive the remaining nodes.
`clean_archive()` permanently deletes every archived Session. Treat it as an explicit empty-trash action and require user confirmation in a UI.
For local maintenance, `remove(session_id, origin_type = "work")` permanently deletes one active Session's Agent-owned data. `clear_messages(session_id, origin_type = "work")` clears only its messages. Power-owned data is not deleted implicitly; call the owning Power when a workflow also needs to remove domain data.
## UI guidance
1. Use lightweight `list()` summaries for the sidebar.
2. After selection, call `get()` and then load `messages()`.
3. Render `executing: true` as the runtime state; do not guess from timestamps.
4. Remove an archived item from the active list and reload archived data after success.
A Session title can be empty. The presentation layer should fall back to `session_id` or a product-defined placeholder.
### session.set({ model })
Source: https://downcity.ai/en/agent-sdk-docs/sessions/set-model/
`session.set({ model })` lets a local SDK Session override the Agent model with its own `ModelClient`. Pass a `CityModel` from the Federation catalog or another client implementing Downcity Model Protocol.
```ts
await session.set({
model,
});
```
## When a runtime switch takes effect
A successful `session.set()` means the configured value was written and queued for the Session. It does not replace an in-flight provider request.
- the current stream and active tool execution keep using the previous model
- the new model becomes effective at the next Session step checkpoint; if the current Session turn ends first, it takes effect when the next Session turn starts
- config changes and steer prompts are committed in queue order
- the Session timeline emits a completed action message when the switch becomes effective
The config API reports whether the change succeeded. The action message reports the Session turn and step where it became effective for model execution. Timeline persistence is observational: if writing that action fails, the effective configuration is not rolled back and the SDK records a warning.
## Controlling the Action and Mutation
The optional second argument to `set()` controls only the observable result after the configuration commits. It does not change configuration persistence or checkpoint behavior:
```ts
await session.set(
{ model },
{ persist_action: false, publish_mutation: false },
);
```
- `persist_action` defaults to `true`. When false, no Agent Message containing the configuration Action Part is created.
- `publish_mutation` defaults to the value of `persist_action`. Set it to false to keep the historical Action Part without publishing its Message Mutation through `subscribe()`.
- `publish_mutation=true` is invalid when `persist_action=false`, because every Message Mutation must correspond to a canonical Message.
- Setting the same model identity again does not create another configuration Action. After restart, passing the same model only rebinds the runtime instance.
Session initialization and restoration can disable both outputs. User-driven model changes normally keep the defaults.
## When you need it
This is useful when:
- you are using `@downcity/agent` in pure SDK embedding mode
- you manage a local Session directly
## Agent default model
When several Sessions share one model, provide the instance directly to the Agent:
```ts
const agent = new Agent({
id: "repo-helper",
model,
});
```
A Session without an explicit model falls back to the Agent model; once set, its own model takes priority. Model instances are not persisted.
## Why it works this way
The local SDK behaves more like an embedded execution shell:
- Agent owns identity, model, custom tools, and powers; AgentSessions owns Sessions and each Session may use project tools
- the caller decides which live model instance should be used
That makes the local SDK more flexible, but it also makes the caller responsible for model readiness.
## What happens if you skip it
If neither the Agent nor the local Session has a model instance, execution fails explicitly.
## What about remote sessions
Remote Sessions do not expose a model-setting API. Remote clients only send prompts; the server creates the local Agent with its runtime model instance.
## What about Downcity Agent projects
If you are working inside a normal Downcity Agent project, the model should usually come from:
- Agent config `execution.modelId`
- the connected `Federation AIService`
In that case, `session.set({ model })` should not be treated as the default path.
### Session Details
Source: https://downcity.ai/en/agent-sdk-docs/sessions/info/
`session.get_info()` returns a lightweight detail snapshot for the current Session. It is for headers, title bars, and current state; it does not replace `messages()`.
```ts
const info = await session.get_info();
render_header({
title: info.title || info.session_id,
preview: info.preview_text,
is_running: info.executing === true,
model: info.model_label,
});
```
## Common fields
| Field | Meaning |
| --- | --- |
| `agent_id` / `session_id` | Stable ownership and identity |
| `title` | An optional persisted title |
| `preview_text` | Recent text preview for lists |
| `message_count` | Number of persisted Messages |
| `model_label` | Runtime model label inferred for observability |
| `created_at` / `updated_at` | Timestamps in milliseconds |
| `executing` | Whether a Turn is currently active |
| `timezone` | Reference timezone recorded when the Session was created |
`get_info()` does not return complete history or current streaming text. Use `messages()` for content and `subscribe()` for live state.
## Actual title semantics
After the first user message, the SDK tries to generate and persist a title with the currently available model. If no model is available or generation fails, the title may remain empty; the SDK does not guarantee a fallback from the first user message.
The UI must fall back to `session_id`, `preview_text`, or its own new-conversation label when `title` is empty. A title update is published live as `variant: "session", type: "title"`.
## List versus detail
- Sidebar: use `agent.sessions.list()` instead of calling `get_info()` for every item.
- Selected Session: use `get_info()` to refresh header and execution state.
- Content area: load history with `messages()` and keep it synchronized with `subscribe()`.
Do not infer execution from `updated_at`; a timestamp indicates recent persistence, not an active Turn.
### Prompts and Turns
Source: https://downcity.ai/en/agent-sdk-docs/sessions/prompt-and-turns/
`session.prompt()` is the only user-input entry point. It accepts one new input and returns a `TurnHandle` once the SDK has assigned that input to a concrete Turn.
```ts
const turn = await session.prompt({
query: "Analyze this repository",
});
const result = await turn.finished;
if (result.success) {
console.log(result.text);
}
```
`prompt()` does not mean “wait for the final model response.” Receive live activity through `subscribe()` and the final result through `turn.finished`.
## Input shapes
`query` accepts a string or a Downcity Session User Part array:
```ts
const turn = await session.prompt({
query: [
{
type: "context",
tag: "reference",
context: "An earlier quoted message",
},
{ type: "text", text: "Explain the architecture in this image" },
{
type: "file",
media_type: "image/png",
url: "/absolute/path/architecture.png",
filename: "architecture.png",
},
],
});
```
Both input and persisted messages use Downcity's `snake_case` fields. Parts passed to `prompt()` do not have a `part_id`; they become canonical User Parts only after entering the Session.
A `context` Part retains structured `tag` and `context` fields in the canonical Message. It becomes Text Content such as `An earlier quoted message` only when model context is assembled. A tag must start with a lowercase letter and may contain only lowercase letters, numbers, underscores, and hyphens; XML structural characters in the body are escaped safely. Context is not an ordinary user body, so provide a `text` Part alongside it in normal prompts.
Data URL attachments are not written directly into Session Messages. The Session decodes each Data URL into the current Session's `.downcity/.../attachments/` directory and stores only a path relative to the Workspace root in the Message. Before model execution, the Session reads the attachment from that path and converts it into a model-consumable representation.
When a Session fork is created, local attachments owned by the source Session are copied into the new Session's attachment directory. The fork therefore keeps its historical attachments when the source Session is later archived or removed.
For remote Sessions, Data URLs are stored on the server. A local path supplied by a remote client only works when the server can access it; prefer a Data URL or a URL accessible by the server.
## Turn handles
```ts
const turn = await session.prompt({ query: "Continue" });
console.log(turn.id);
console.log(turn.result); // null before completion
const result = await turn.finished;
```
`finished` never rejects for a model or tool failure. It always resolves to:
```ts
{
turn_id: string;
success: boolean;
text: string;
error?: string;
}
```
Application code must check `success`; waiting for promise fulfillment alone is not a success check.
Read the structured Agent Message through `messages()` and `subscribe()`; the Turn result does not duplicate it. On failure, the Session closes the Agent Message and persists the failure reason as a user-visible Error Part.
If a Tool throws, returns `{ success: false }`, or receives malformed JSON arguments from the model, the SDK marks that same Tool Part as `failed` and sends a failed Tool Result into the next model step. These failures do not end the Turn by themselves. A model Provider stream, canonical Message write, or other execution-infrastructure failure can end the Turn. A transient Provider failure is retried up to five times only when no visible output was produced, for at most six calls including the initial request. After partial output, the draft is preserved and the Turn fails explicitly to avoid duplicated text.
Every failed model request publishes a live `warning / model_request` Mutation. `request_kind` distinguishes Turn, title, and history-compaction calls. `attempt` and `max_attempts` identify the current and maximum call counts, while `will_retry` tells the subscriber whether another call will follow. Main Turn Warnings include `turn_id`; background Session requests may omit it. The final failure has `will_retry: false`. A user-requested cancellation does not publish a Warning.
If an OpenAI-compatible Provider closes its SSE stream in the middle of an event, the SDK reports a retryable `transport_error`. A complete SSE event containing invalid JSON produces a non-retryable `provider_error`. Public errors use stable descriptions rather than exposing the underlying JSON parser message.
## Sending input while a Turn runs
You can call `prompt()` again while a Session is running:
```ts
const first_turn = await session.prompt({ query: "Analyze the directory layout first" });
const follow_up = await session.prompt({ query: "Only inspect packages/agent" });
```
The Session decides what happens to the second input:
| Situation | Result |
| --- | --- |
| The current Turn can still absorb input | It is persisted as a canonical User Message and merged into the current Turn |
| The Turn has passed that boundary | It is persisted as a normal User Message and queued for the next Turn |
| The Session is idle | A new Turn starts immediately |
Two `prompt()` calls can return the same `turn.id`. That means they were merged into one execution, not executed twice. Do not build a second client-side queue or assume every call starts a new Turn.
## Live rendering and final result
A typical UI does both:
```ts
const unsubscribe = session.subscribe(render_mutation);
const turn = await session.prompt({ query: "Produce an implementation plan" });
const result = await turn.finished;
unsubscribe();
```
- `delta` appends visible text, reasoning text, or raw Tool input immediately.
- `part` updates the complete state of the same tool or text Part.
- `turn.finish` updates loading state and aligns with `turn.finished`.
Do not poll `turn.result` for streaming content. It becomes the final snapshot only after the Turn ends.
## Empty input and errors
Empty strings, empty arrays, and arrays containing only empty text Parts are rejected. Disable empty submission in the UI and still handle errors such as remote connection failure, a missing model, or an archived Session.
Use [session.stop()](/en/agent-sdk-docs/sessions/stop) to stop a running Turn.
### Stopping a Turn
Source: https://downcity.ai/en/agent-sdk-docs/sessions/stop/
`session.stop()` stops the currently running Turn and cancels queued input that has not yet been absorbed by that Turn.
```ts
const turn = await session.prompt({ query: "Run a long task" });
const stop_result = await session.stop(turn.id);
console.log(stop_result);
const result = await turn.finished;
```
## Return value
```ts
type AgentSessionStopResult = {
stopped: boolean;
turn_id?: string;
cancelled_queued_prompts: number;
reason: "stopped" | "idle";
};
```
| Field | Meaning |
| --- | --- |
| `stopped` | Whether an active Turn was stopped or queued input was cleared |
| `turn_id` | The active Turn that was stopped; present only for an active Turn |
| `cancelled_queued_prompts` | Number of inputs not yet assigned to a Turn |
| `reason` | `stopped`, or `idle` when there was nothing to stop |
## Input queue semantics
When users submit input consecutively, some input may already be merged into the current Turn as steering and some may still await the next Turn. Stop uses this boundary:
```text
Input already absorbed by the current Turn -> stops with that Turn
Input still queued and not absorbed -> is cancelled and never starts automatically
```
So a Stop button does not immediately start another execution because text remains in the queue. If a product must preserve a draft, keep it in UI draft state before calling `stop()` rather than relying on the SDK queue.
## Final state
A stopped Turn still settles `turn.finished`:
```ts
const result = await turn.finished;
console.log(result.success); // false
console.log(result.error); // usually Turn stopped
```
The subscription also receives `variant: "turn", type: "finish", status: "stopped"`. Never leave code awaiting `finished` suspended because a stop occurred.
Calling `stop()` on an idle Session does not throw. It returns `stopped: false` and `reason: "idle"`, making a UI stop action idempotent.
### Messages and Parts
Source: https://downcity.ai/en/agent-sdk-docs/sessions/messages/
`session.messages()` reads stable SQLite snapshots, paginated on Message boundaries. Use `subscribe()` for live changes.
```ts
const page = await session.messages({ limit: 50 });
for (const message of page.items) render_message(message);
```
```ts
interface SessionMessagePage {
items: SessionMessage[];
total: number;
start_sequence?: number;
end_sequence?: number;
next_before_sequence?: number;
has_more: boolean;
}
```
`total` is the number of real Messages in the Session, not `items.length`. `limit` defaults to 50 and is capped at 200.
## Two top-level Message types
```ts
type SessionMessage = SessionUserMessage | SessionAgentMessage;
```
| `role` | Meaning | Top-level state |
| --- | --- | --- |
| `user` | User input or an internal recovery instruction | No state field; complete when written |
| `agent` | Agent output and runtime records | `state: "streaming" \| "done"` |
A User Message does not distinguish prompt from steer. Whether it starts a Turn or joins the current Turn is represented by `turn_id` and decided by SessionLoop, not duplicated in message content.
An Agent Message's `state` only answers whether Parts can still be appended. Success, stop, and failure belong to the Turn result. Persisted stop and failure reasons use an `error` Part.
A Session has one two-party Message sequence ordered by `sequence`. Tools, Interactions, Actions, and Errors are Agent Parts rather than independent Messages. Multiple model Steps in an ordinary Tool Loop may append to the same Agent Message; model-produced Parts carry their source `step_id`.
Action ownership rule: while the target Turn still has a streaming Agent Message, the Action Part is appended directly to that Message and shares its canonical identity (for example, changing the model or approval mode mid-run). Only when the Session is idle, the target Turn has no Agent Message yet, or the existing Message is already closed does an Action fall back to a standalone Agent Message containing just the Action Part.
## User Parts
| `type` | Key fields | Meaning |
| --- | --- | --- |
| `text` | `text` | User body |
| `context` | `tag`, `context` | Semantically tagged model context |
| `file` | `url`, `media_type`, `filename` | A user-submitted file |
| `data` | `data_type`, `data`, `data_id` | Persisted host/UI data not sent to the model |
A User Message can also carry a `meta` field: message-level request bookkeeping recording who started this input and the bindings unique to this request. It holds `from` (the originator, required), plus optional `powers` and `callback`. It is not a Part, so it never enters the model context and is never rendered in chat; a message editor cannot restore, edit, or send it back. Only User Messages may carry it.
User Parts have no streaming state. A `context` Part becomes safely tagged text only at the model boundary:
```ts
// Canonical User Part
{ part_id: "user-context:1", sequence: 1, type: "context", tag: "reference", context: "Earlier message" }
// Model Content
{ type: "text", text: "Earlier message" }
```
`visibility: "internal"` uses the same User Message shape. Recovery instructions that affect the next Step are persisted first and then read from canonical history by the Composer; there is no separate runtime-input protocol.
## Agent Parts
| `type` | Key fields | Meaning |
| --- | --- | --- |
| `text` | `text`, `state` | Visible output, `streaming -> done` |
| `reasoning` | `text`, `state` | Reasoning output |
| `tool` | `tool_call_id`, `tool_name`, `state`, `input`, `output` | Complete Tool lifecycle |
| `interaction` | `interaction_id`, `status`, `request`, `response` | Async approval or questions |
| `file` | `url`, `media_type`, `filename` | Agent file result |
| `data` | `data_type`, `data`, `data_id` | Structured UI data |
| `action` | `action_id`, `action_type`, `state`, `title` | Session operation state |
| `error` | `scope`, `code`, `message`, `recoverable` | Session or Turn error |
Every Part has a stable `part_id` and immutable `sequence`. A Tool state transition updates the same Part instead of creating duplicate request/result messages. A Tool and its related Interaction transition commit in one checkpoint transaction.
## Streaming and persistence boundary
```text
Events drive live rendering
Memory assembles the stream
The database stores stable semantic checkpoints
```
Model stream start, delta, and finish events first update the current Agent Message's in-memory projection and publish `part` / `delta` Mutations. Ordinary chunks do not write SQLite. When a model Step completes, the SDK validates Part identity and commits the complete Step snapshot in one transaction.
Tool transitions, Interaction changes, Actions/Errors, and Message closure are durable semantic checkpoints and may commit independently. A committed `message` Mutation replaces the live projection with a complete snapshot.
## Ordering, revision, and reconnects
Every Message has a stable `message_id`, immutable `sequence`, and increasing `revision`. Clients should:
1. Subscribe and buffer Mutations before loading the initial snapshot.
2. Merge complete `message` snapshots by `message_id + revision`.
3. Apply `part` and `delta` only while the live stream is continuous.
4. Reload snapshots after disconnect instead of assuming Deltas can be replayed.
An interrupted, unfinished Step projection may be discarded. The Session closes the persisted streaming Agent Message as `done` and appends an Error Part, so SQLite is authoritative after reconnect.
## Loading older history
```ts
let page = await session.messages({ limit: 50 });
while (page.has_more && page.next_before_sequence !== undefined) {
page = await session.messages({
before_sequence: page.next_before_sequence,
limit: 50,
});
prepend_messages(page.items);
}
```
By default, only `visibility: "visible"` Messages are returned. Pass `include_internal: true` for debugging or audit. Composer Policy summaries are derived data: they consume no Message sequence and never appear in `messages()`.
See [Reconnect and sync](/en/agent-sdk-docs/sessions/reconnect-and-sync) for synchronization and [Metadata and storage](/en/agent-sdk-docs/sessions/metadata-and-storage) for persistence details.
### Live Subscriptions
Source: https://downcity.ai/en/agent-sdk-docs/sessions/subscribe/
`session.subscribe()` is the only live Session entry point. It broadcasts only changes after subscription, with no cache, replay, or backpressure management.
```ts
const unsubscribe = session.subscribe((mutation) => {
apply_mutation(mutation);
});
// Always release it when it is no longer needed.
unsubscribe();
```
The callback may be async. A thrown error or rejection from one subscriber does not roll back persisted state or block other subscribers.
## Eight Mutation variants
Every Mutation has `mutation_id`, `session_id`, and `created_at`.
| `variant` | `type` | Purpose | Has Message revision |
| --- | --- | --- | --- |
| `message` | `user | agent` | Complete top-level Message snapshot | Yes |
| `part` | `text | reasoning | tool | file | data` | Complete Assistant Part snapshot; a `tool` Part carries its `interactions` | Yes |
| `delta` | `text | reasoning | tool_input` | New raw text or Tool input fragment | Yes |
| `turn` | `start | finish` | Turn execution lifecycle | No |
| `file_diff` | — | Live file-change summary for the current Turn | No |
| `session` | `title | config` | Session property change | No |
| `warning` | `model_request` | Model request failure and retry progress | No |
`message`, `part`, and `delta` are published only after the corresponding complete Message is persisted. The Mutation envelope itself is never written to canonical history.
## Render text with Deltas
```ts
session.subscribe((mutation) => {
if (mutation.variant !== "delta" || mutation.type !== "text") return;
append_text({
message_id: mutation.message_id,
part_id: mutation.part_id,
delta: mutation.delta,
});
});
```
A Delta is an appended fragment, not cumulative text. Do not use it as a complete Part snapshot and do not infer order from text content; use `message_id`, `part_id`, and `revision`. A `tool_input` Delta also carries the matching `tool_call_id`.
## Correct state with complete snapshots
Network behavior, batched rendering, and UI transitions can leave client-maintained intermediate state incomplete. `message` and `part` are complete corrective snapshots:
```ts
function apply_mutation(mutation: SessionMutation): void {
if (mutation.variant === "message") {
upsert_message(mutation.message, mutation.revision);
return;
}
if (mutation.variant === "part") {
upsert_part(mutation.message_id, mutation.part, mutation.revision);
return;
}
if (mutation.variant === "delta") {
if (mutation.type === "tool_input") {
append_tool_input_delta(
mutation.message_id,
mutation.part_id,
mutation.tool_call_id,
mutation.delta,
);
} else {
append_text_delta(mutation.message_id, mutation.part_id, mutation.delta);
}
}
}
```
Track the greatest applied `revision` for each Message. A `message` or `part` snapshot at or below that revision can be ignored so an old event cannot overwrite newer state.
## Turns and title updates
```ts
session.subscribe((mutation) => {
if (mutation.variant === "turn" && mutation.type === "start") {
set_turn_running(mutation.turn_id);
}
if (mutation.variant === "turn" && mutation.type === "finish") {
set_turn_finished(mutation.turn_id, mutation.status, mutation.error);
}
if (mutation.variant === "session" && mutation.type === "title") {
set_session_title(mutation.title);
}
});
```
`turn.finish` has `completed`, `failed`, or `stopped` status. It and `turn.finished` describe the same final execution: the former is for subscription-driven UI, the latter for control flow.
Context compaction is not a separate Mutation and has no public Handle. `SessionComposer.advance_context()` is recorded as an Action Part with `action_type` set to `context-compaction`: once compactable content is confirmed, it publishes `running`, then updates the same Action ID to `completed` or `failed`. Message subscribers can display the operation while the summary is being generated. No Action is created when there is nothing to advance. Custom Composers should call and await `input.on_advance_start?.()` after confirming an eligible range and before starting expensive work to publish the running state. It never rewrites canonical history; it only advances the Composer's derived tables (see [Metadata and storage](/en/agent-sdk-docs/sessions/metadata-and-storage)).
On a failed model call, `warning / model_request` carries `request_kind`, `code`, `message`, `retryable`, `attempt`, `max_attempts`, and `will_retry`. Main Turn requests also include `turn_id`; background title and history-compaction requests may omit it. It is a live diagnostic and is not persisted as Message history. `retryable` reflects the Provider error classification; `will_retry: false` means no further automatic retry follows this failure.
## Tool Part state machine
The same Tool Part is updated under the same `part_id`:
```text
input-streaming -> ready -> waiting-user -> running -> completed
\-> failed
ready ----------------------------------------------> running
```
`input-streaming` means the model is still producing tool arguments. During this phase, append `tool_input` Deltas to the Tool Part's `input_text`; the `ready` Part is the corrective snapshot after input completes and carries the parsed `input`. Key UI elements by `tool_call_id` or `part_id`; do not append a new item for every Delta.
If the completed argument stream is not valid JSON, the same Part moves directly from `input-streaming` to `failed`, with `error` describing the parse failure; the Tool implementation is not executed. A structured `{ success: false }` Tool return also produces `failed`. Clients should render `state` and `error` instead of inferring success from output text.
See [Tool approvals](/en/agent-sdk-docs/sessions/approvals) for approval handling.
## Lifecycle boundary
`unsubscribe()` stops only future events. It does not stop a Session, Turn, or tool. Call it on route, component, or Session changes; when returning to the Session, reload snapshots and subscribe again.
Reconnecting requires more than subscribing again. See [Reconnect and sync](/en/agent-sdk-docs/sessions/reconnect-and-sync).
### Reconnect and Sync
Source: https://downcity.ai/en/agent-sdk-docs/sessions/reconnect-and-sync/
`subscribe()` only sends future changes and `messages()` returns a point-in-time snapshot. Combining them in the wrong order creates a race: a Delta can be lost between loading the snapshot and establishing the subscription.
The reliable sequence is subscribe and buffer first, load snapshots second, then merge the buffered events.
## Recommended algorithm
```ts
const buffered: SessionMutation[] = [];
let snapshot_ready = false;
const unsubscribe = session.subscribe((mutation) => {
if (!snapshot_ready) {
buffered.push(mutation);
return;
}
apply_mutation(mutation);
});
const [message_page, info] = await Promise.all([
session.messages(),
session.get_info(),
]);
replace_message_state(message_page.items);
replace_session_info(info);
for (const mutation of buffered) {
apply_mutation(mutation);
}
snapshot_ready = true;
```
A real UI should process `buffered` and set `snapshot_ready = true` in one serialized state transition so a new event cannot arrive in another gap.
## Merge rules
| Mutation | Merge strategy |
| --- | --- |
| `message` | Upsert by `message_id`; apply only a greater `revision` |
| `part` | Upsert by `part_id` in its Assistant Message; apply only a greater `revision` |
| `delta` | Append `delta` to matching text/reasoning `text` or Tool `input_text` |
| `turn` | Update runtime state by `turn_id` |
| `session` | Update Session metadata such as the title |
After a disconnect, do not assume Deltas can be replayed. Deltas from completed steps are already part of a stable SQLite snapshot; an interrupted unfinished projection may be discarded and closed with an Error Part. Reload `messages()` to restore authoritative state.
## RemoteSession disconnects
When a remote connection closes:
1. Retain the current UI snapshot for temporary display, but do not treat it as final truth.
2. Clean up the old subscription.
3. Establish a new subscription and buffer Mutations.
4. Reload `messages()` and `get_info()`.
5. Apply buffered events under the merge rules above.
If a client is awaiting `turn.finished` when the connection breaks, handle its failed result and restore the UI by reloading the Session. Do not synthesize Assistant completion from client-side timers.
## History pagination
The initial snapshot loads the most recent Message page. To load earlier history, pass its `next_before_sequence` with an appropriate `limit`. Live Mutations update the current snapshot; never let an older revision replace a loaded Message.
## Deduplication
Events from one connection are normally ordered, but a client should still be idempotent:
- Use `message_id + revision` to reject old snapshots.
- Use the parent Message revision for Parts.
- Append Deltas only while the stream is continuous; validate `tool_input` against its `tool_call_id`; after reconnect, prefer the snapshot.
- `mutation_id` can deduplicate repeated delivery of the same event.
This approach works for local and remote Sessions. Only the remote transport commonly disconnects.
### Asynchronous User Interactions
Source: https://downcity.ai/en/agent-sdk-docs/sessions/approvals/
An Interaction means that the current Turn needs user participation. The first release supports
`approval` and `question` through one lifecycle, query API, and response API.
An Interaction is an Assistant Message Part, not a top-level Message or a new Turn. For a Tool
source, the Tool and Interaction change atomically in one Assistant revision:
```text
create: Tool ready -> waiting-user + Interaction pending
respond: Interaction resolved + Tool running / failed
close: Interaction cancelled + Tool failed
```
## Subscribe and respond
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (!interaction) return;
render_interaction(interaction);
});
await session.respond({
interaction_id,
response: {
type: "approval",
outcome: "resolved",
payload: { decision: "approved" },
},
});
```
`subscribe()` only reports persisted state. `respond()` atomically commits the Interaction and
related Tool before it resumes execution. A missing or terminal request, or a response-type
mismatch, throws an explicit error.
## Query pending Interactions
```ts
const pending = await session.interactions();
for (const request of pending) {
if (request.type === "approval") render_approval(request);
else render_questions(request.payload.questions);
}
```
Approval requests also carry a `response_schema` that describes the required response shape.
Shell approval and Tool approval share one default schema, exported as
`SESSION_APPROVAL_RESPONSE_SCHEMA` (it requires `decision` to be `approved` or `denied`).
Questions support `text`, `single_select`, and `multi_select`. Answers must cover every requested
`question_id`:
```ts
await session.respond({
interaction_id,
response: {
type: "question",
outcome: "resolved",
payload: { answers: [
{ question_id: "region", value: "cn" },
{ question_id: "features", value: ["search", "files"] },
] },
},
});
```
## Model-initiated questions
`ask_question` is optional and is not registered by Agent by default. Add it explicitly when the
model needs this capability:
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { AskQuestionsTool } from "@downcity/agent/tools";
const agent = new Agent({
id: "repo-helper",
tools: {
ask_question: AskQuestionsTool,
},
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
```
When missing information would materially change the outcome, the model returns a standard Tool Call:
```json
{
"title": "Choose a deployment region",
"questions": [
{
"question": "Which region should receive the deployment?",
"type": "single_select",
"options": [
{ "value": "cn", "label": "China" },
{ "value": "us", "label": "United States" }
]
}
]
}
```
Every item in `questions` must explicitly provide `type`: `text`, `single_select`, or
`multi_select`. The Session generates `question_id` values automatically. Select questions must
provide at least one option with both `value` and `label`; text questions do not need `options`.
The Session atomically commits the Tool and Question Interaction as `waiting-user` / `pending`.
After the host submits all answers through `session.respond(...)`, the Tool Result is:
```json
{
"status": "resolved",
"answers": [{ "question_id": "question:generated-id", "value": "cn" }]
}
```
The model then continues in the next Step of the same Turn. A question written only as assistant
text does not enter this lifecycle: it completes the current Turn, and the next user message starts
a new Turn.
## General Tool approvals
An AgentTool that requires user permission requests approval inside its own execution through the
injected tool call context:
```ts
import { define_agent_tool } from "@downcity/type";
import { z } from "zod";
const delete_file = define_agent_tool({
description: "Delete a specified file from the workspace.",
input_schema: z.object({
path: z.string(),
}),
execute: async ({ path }, context) => {
// The Agent injects the approval port; ask waits for the user, always-allow passes.
const decision = await context.interactions.approval.request({
turn_id: context.turn_id,
tool_call_id: context.tool_call_id,
tool_name: "delete_file",
payload: { operation: "tool", validated_input: { path } },
});
if (!decision.approved) {
return { success: false, error: "denied" };
}
await remove_workspace_file(path);
return { deleted: path };
},
});
const agent = new Agent({
id: "repo-helper",
model,
tools: { delete_file },
});
// The Session picks its Workspace at creation time.
```
The approval policy belongs to the Tool, not the Agent. After the model produces a Tool Call, the
Agent validates its input against the schema and enters the Tool's own `execute`. The Tool creates
an Approval Interaction with `operation: "tool"` there and waits for `session.respond(...)`.
Approval continues to the side effect; denial does not run it.
The kernel no longer holds a tool-level approval hook: the Tool decides which step needs a human
decision, and the Session decides how that decision is answered. The approval mode (`ask` /
`always-allow`) takes effect inside the approval entry, so the Tool never reads the mode itself.
Under `always-allow` no Interaction is created and the decision comes back approved.
`interactions.request()` is the generic primitive, and `interactions.approval` is the approval
entry. To start any interaction, supply only semantic fields; `interaction_id`, `created_at` and
`source` are filled in by the primitive:
```ts
const handle = await session.interactions.request({
type: "question",
turn_id,
tool_call_id,
payload: { questions },
});
const result = await handle.result; // the producer interprets the payload
```
An approval request gives the UI the actual invocation data:
- `source.tool_name` and `source.tool_call_id`: the Tool and call identity.
- `payload.validated_input`: structured input that passed schema validation.
- `payload.tool_description`: the stable capability description from the Tool definition, when present.
- `payload.model_explanation`: optional explanation generated by the model for this call; it is not a
security signal.
The host does not configure an approval `title` or `reason`. The UI should present the Tool,
validated input, and optional model explanation without replacing the actual input with a
client-generated summary.
## Shell approval mode
```ts
const current = await session.status();
await session.set({
security: {
approval_mode: "always-allow",
},
});
```
| Mode | Behavior |
| --- | --- |
| `ask` | A host Shell request creates an approval Interaction. |
| `always-allow` | Future Shell requests in this Session are approved automatically. |
Approval mode is a Shell Adapter execution policy, not general Interaction state. It does not
change the sandbox, affect other Sessions, or resolve already-pending Interactions.
`set()` accepts the configured value and appends one `SessionCommand` to the Session's ordered input
queue. The current Provider request and Tool callbacks keep their captured policy; the new mode
becomes effective at the next Session Step checkpoint. If the current Turn has no next Step, the
command is applied before the next prompt.
Read the projection with `status()`: `security.approval_mode` is the configured value and
`security.effective_approval_mode` is the execution value. When they differ, the update is still
queued. After the Command commits, the Session persists a completed `Session configuration updated`
Action.
Initialization or restoration can suppress that Action and its Mutation through the optional second argument to `set()`. The configuration is still written and committed at the checkpoint:
```ts
await session.set(
{ security: { approval_mode: "always-allow" } },
{ persist_action: false, publish_mutation: false },
);
```
Setting the same approval mode is idempotent and does not create another configuration Action. The approval mode is restored from Session metadata.
## UI implementation details
- Use `interaction_id` as the stable card key.
- For any source with `source.tool_call_id`, link it with that Tool Call ID.
- Keep a `waiting-user` Tool in a waiting state; do not synthesize running or success client-side.
- `resolved`, `denied`, `cancelled`, and `failed` are terminal and cannot be answered again. Interactions never time out: they end when the user responds or when the Turn/Session closes.
- Clear old Interaction UI when switching Sessions; never send an old Session's ID to a new one.
See [Messages and Parts](/en/agent-sdk-docs/sessions/messages) for complete Part states.
### session.system()
Source: https://downcity.ai/en/agent-sdk-docs/sessions/system/
## Example
```ts
const system = await session.system();
```
## What It Returns
It returns a structured system snapshot:
```ts
{
session_id: string;
session: {
agent_id: string;
session_id: string;
project_root: string;
created_at: string;
timezone: string;
};
blocks: Array<{
source: "core" | "instruction" | "power" | "session";
name: string;
content: string;
}>;
}
```
The blocks can include:
- the Agent identity block, carrying the Agent `name` and `id`
- caller-provided static `instruction`
- Downcity Agent core guidance, including Harness design, Shell command rules, and power system rules
- registered and enabled power system text
- stable session context, including `session_id`, `project_root`, creation time, and timezone
The returned blocks are the same stable system blocks used by `session.prompt()`. The first generated system is fixed in the Session's memory; later Agent instruction or power registry changes do not alter it. Newly created Sessions use the latest configuration.
To explicitly adopt the Agent's current configuration in an existing local Session, call [`session.syncshot()`](/en/agent-sdk-docs/sessions/syncshot).
The session creation time and timezone are the reference time context for the conversation. Dynamic values such as current time should be placed in the user message, not in `instruction`.
## Block Order
`@downcity/agent` builds system blocks in a fixed order:
1. `instruction` / `identity`: the Agent `name` and `id`, injected by the SDK. The Agent identity is stable across every Session of that Agent.
2. `instruction` / `agent`: the host-provided Agent role, business rules, and response preferences.
3. `core`: fixed Downcity Agent runtime guidance, including Harness design, Shell command rules, and power system rules.
4. `power`: each enabled, ready, and currently available power's own system prompt.
5. `session`: stable context for the current session.
Even when the host provides `instruction`, `core` is still injected immediately after it. The `session` block describes the session itself and does not repeat the Agent identity.
## Example Snapshot
```json
{
"session_id": "design-review",
"session": {
"agent_id": "reviewer",
"session_id": "design-review",
"project_root": "/path/to/project",
"created_at": "2026-05-19T14:16:25.048Z",
"timezone": "Asia/Shanghai"
},
"blocks": [
{
"source": "instruction",
"name": "identity",
"content": "# Agent Identity\n\nYou are \"Code Reviewer\" (agent id: reviewer)."
},
{
"source": "instruction",
"name": "agent",
"content": "You are the Downcity project assistant."
},
{
"source": "core",
"name": "default",
"content": "# Downcity Agent\n\nYou are a Downcity Agent running inside the Downcity Agent runtime...\n\n# Harness Design\n\nDowncity Agent is a harness around an existing human-owned workspace, not a replacement platform...\n\n# Shell Commands\n\nAll Shell work goes through the `shell` power...\n\n# Power System\n\nYou are working in a power-based execution environment..."
},
{
"source": "power",
"name": "task",
"content": "# Task Power\n\nYou can use the `task` power to manage task definitions, execution, scheduling, and status control..."
},
{
"source": "session",
"name": "context",
"content": "Current session context:\nThis session is \"design-review\".\nThe current project root is \"/path/to/project\".\nThis session was created at 2026-05-19T14:16:25.048Z, with Asia/Shanghai as its reference timezone.\nThis creation time is a stable reference for the session and does not represent the current time for every run.\nIf the user message provides a newer current time, a relative time, or other dynamic context, prioritize the dynamic information from the user message."
}
]
}
```
## What It Does Not Do
`session.system()` does not write system prompts into canonical Message history.
Session history stays focused on visible user and assistant messages. Use `system()` when you need to inspect the prompt context that will be sent alongside that history.
### session.syncshot()
Source: https://downcity.ai/en/agent-sdk-docs/sessions/syncshot/
A local Session fixes its system in memory after the first generation. To explicitly adopt the Agent's latest instruction, managed system blocks, and power system text, call:
```ts
await session.syncshot();
```
`syncshot()` replaces the Session's in-memory system snapshot. A provider request already in flight is unaffected; later steps use the new system. It refreshes only the system and does not change the Session's power execution view.
Persistence depends on whether the Session already has an `instruction.md` file:
- Existing file: atomically overwritten with the regenerated complete system.
- Missing file: only memory is updated; no file is created automatically.
To create `instruction.md` for the first time, explicitly persist after refreshing:
```ts
await session.syncshot();
await session.snapshot();
```
`syncshot()` and `snapshot()` run serially within one Session so concurrent writes have deterministic ordering. This method is available only on local `AgentSession`; remote Sessions do not expose it.
### Forking a Session
Source: https://downcity.ai/en/agent-sdk-docs/sessions/fork/
`session.fork()` creates an independent new Session from the current Session's history. Later input, model execution, tool state, and approvals are isolated between the branches.
```ts
const full_branch = await session.fork();
const branch_at_message = await session.fork({
message_id: "assistant:repo-analysis:abc",
});
```
You can also pass a `message_id` string directly:
```ts
const branch = await session.fork("assistant:repo-analysis:abc");
```
## Replace history in place
Pass `replace: true` to truncate the current Session and return the same instance. Its ID, configuration, and retained Message identities stay intact. This creates no branch, archives nothing, and writes no Action record.
```ts
await session.fork({
message_id: "user:question:abc",
include_message: false,
replace: true,
});
await session.prompt({ query: "The edited question" });
```
`include_message` defaults to `true`; setting it to `false` also removes the boundary Message. Later history is permanently deleted, together with related context summaries. Without `message_id`, history stays unchanged. Replacement is rejected while the Session is executing or has queued commands.
Subscribers receive a `variant: "session"`, `type: "truncate"` event. Remove messages after its `through_sequence`; do not display this synchronization event as a timeline Action. Both local and remote Sessions support this option.
## History boundaries
| Call | Copied range |
| --- | --- |
| `fork()` | The complete current visible history |
| `fork({ message_id })` | History from the beginning through that Message, inclusive |
| `fork({ message_id, include_message: false })` | History before that Message, exclusive |
The boundary is a top-level Message, not an Assistant `part_id`. To experiment before or after a tool call, first locate the containing Assistant Message's `message_id` through `messages()`.
## Origin and ordering
Messages copied into the new Session receive the new Session identity and linear ordering while they can carry `origin` with the source `session_id`, `message_id`, and `turn_id`. This makes a fork traceable without sharing mutable state.
A successful fork records one `history-fork` Action at the end of the new Session's timeline, describing where the history came from. The source Session gets no record of the fork.
Do not treat a fork as two windows jointly editing one history. Later source Messages do not appear in the branch, and later branch Messages never write back to the source.
## Good use cases
- Compare implementation choices while preserving the main conclusion.
- Ask again from one user input without later context influencing the answer.
- Create an experimental path for a long task without polluting the main conversation.
Use the fork as any normal Session: load `messages()`, establish `subscribe()`, then call `prompt()`. Do not copy Mutations or pending approvals from the source Session.
### Metadata and storage
Source: https://downcity.ai/en/agent-sdk-docs/sessions/metadata-and-storage/
A persisted Session belongs to the Agent storage scope provided by City. Without City, the default is
in-memory storage. Use the Session API instead of treating these files as an application database.
```text
~/.downcity/agents//sessions///
├── session.db
└── attachments/
```
Each Session has one `session.db` with three stable tables:
- `session_state`: identity, the current directory, configuration, title, preview, and the system snapshot.
- `messages`: the User/Agent Message envelope.
- `message_parts`: one row per Part; each content value belongs to one Part.
The API reconstructs `SessionMessage` aggregates when reading. The table layout is not exposed to the UI
or model providers. Canonical history is never deleted or rewritten by context compaction.
`session_state.workspace_id` records the session's current directory, not its identity: it can change
during a conversation without affecting the `session_id`, the history, or attachment ownership. The
agent's own conversation lives in the fixed `main` partition at `sessions/main/main/`.
## Composer and derived context
`SessionComposer` generates the final system, messages, and tools sent to the model. The caller passes
canonical history and a namespaced derived store into every call; a Composer never opens Session storage
by itself.
The default `DefaultSessionComposer` treats each Part as the context-processing boundary and stores
checkpoints in its own `composer_adaptive_part_checkpoints` derived table. Its `compose()` returns:
```text
explicit system block
+ canonical Parts after the summary boundary
```
Summaries and any other projected context are rebuildable derived data, not Messages. When a provider
reports a context limit, or real usage crosses the pressure threshold, Session asks the Composer to
advance its derived state through `advance_context()`. There is no public `session.compact()`, Active file,
Segment file, or second message log.
## Recovery and transactions
The Message envelope and its Parts commit in one SQLite transaction; Mutations publish only after commit.
On startup, SessionMessages closes the non-terminal Agent Messages and running Actions selected by Storage;
Storage does not interpret domain terminal states.
Attachments remain under `attachments/`, while Parts store attachment references.
Model streaming follows one boundary: events drive live rendering, memory assembles the stream, and SQLite stores only stable semantic checkpoints. Ordinary chunks do not write SQLite; complete model steps, Tool transitions, Interactions, Actions/Errors, and Message closure do.
Legacy JSONL data is not migrated by Desktop or Runtime. Run the external one-time
`scripts/migrate-session-storage-to-sqlite.mjs` script when migration is needed. Development databases
already written with an older SQLite schema use `scripts/migrate-session-sqlite-v3.mjs`; Runtime only checks the
version and never mutates an old database implicitly. Databases written before Interactions became part of
their Tool Part still carry standalone Interaction rows and are rejected on read; run
`scripts/migrate-session-interaction-into-tool.mjs` to move them back into the owning Tool Part.
### Build a Chat UI
Source: https://downcity.ai/en/agent-sdk-docs/sessions/build-a-chat-ui/
A usable Session chat UI does not need a second Timeline. It maintains one linear state of `SessionMessage` values and merges Mutations into that state.
## Initialize
Subscribe and buffer first, then load snapshots so startup cannot lose a Delta:
```ts
const buffered: SessionMutation[] = [];
let ready = false;
const unsubscribe = session.subscribe(async (mutation) => {
if (!ready) {
buffered.push(mutation);
return;
}
await apply_live_mutation(mutation);
});
const [page, info] = await Promise.all([
session.messages(),
session.get_info(),
]);
set_messages(page.items);
set_session_info(info);
for (const mutation of buffered) {
await apply_live_mutation(mutation);
}
ready = true;
```
See [Reconnect and sync](/en/agent-sdk-docs/sessions/reconnect-and-sync) for full race handling.
## Submit and stop
```ts
async function submit(query: string): Promise {
const turn = await session.prompt({ query });
set_active_turn(turn.id);
const result = await turn.finished;
clear_active_turn(turn.id);
set_turn_status(turn.id, result.success ? "completed" : "failed");
}
async function stop(): Promise {
const result = await session.stop();
if (result.cancelled_queued_prompts > 0) {
show_notice("Input that had not started was cancelled");
}
}
```
Disable Send for empty input. Drive the Stop button from Turn Mutations or `get_info().executing`.
## Rendering rules
- Render the conversation by top-level Message `sequence`.
- Render text, tool, then text inside an Assistant by Part `sequence`.
- Render one role heading per Assistant Message. Parts from Tool Loops and Provider continuations stay in that same role container.
- Append `text` / `reasoning` Deltas to the current text Part and `tool_input` Deltas to the current Tool Part's `input_text`.
- Replace complete Part state by stable `part_id`.
- Replace complete Message state by `message_id + revision`.
- Treat the Error Part in the closed Agent Message as the single user-visible error for one failure.
- Render controls for a `pending` Interaction and a waiting state for its `waiting-user` Tool.
Do not create Transcript Message DTOs, flatten tool calls into another persisted list, or overwrite an Agent Message already rendered by Parts with final `turn.text`. Use `turn.finished.error` only for control flow and logging; when the UI already renders an Error Part, do not append another error notice.
## Approval card
Approval UI gets the same identity from a pending Interaction on its Tool Part or from `session.interactions()`:
```ts
await session.respond({
interaction_id,
response: { type: "approval", outcome: "resolved", payload: { decision: "approved" } },
});
```
After submission, wait for the next Part Mutation. Change tool display state only when the SDK publishes `running`, `completed`, or `failed`.
## Session switch and unmount
Switch Sessions in this order:
1. `unsubscribe()` from the old Session.
2. Clear old messages, Turn state, and approval UI state.
3. After obtaining the new Session, run initialization.
4. Never reuse the old Session's `interaction_id`, `part_id`, or Turn loading state.
This boundary prevents approval decisions from targeting the wrong Session and prevents old Deltas from appearing in the new history.
### session.snapshot()
Source: https://downcity.ai/en/agent-sdk-docs/sessions/snapshot/
A local Session fixes its system in memory the first time it is generated. Later Agent instruction or power registry changes do not alter an existing Session's system; newly created Sessions use the latest configuration.
Call `snapshot()` to explicitly persist the complete system as the Session's `instruction.md`:
```ts
await session.snapshot();
```
The file is stored at:
```text
~/.downcity/agents//sessions///instruction.md
```
When restoring the Session, the SDK reads this file first. If it does not exist, the Session regenerates its system from the Agent's current instruction and powers. Deleting `instruction.md` cancels persistence and restores that behavior.
`snapshot()` writes the complete system, including the Agent identity block, custom instruction, SDK core, Power system, and Session context. It does not persist message history, model, or environment variables. Multiple system blocks are merged in order into one Markdown document, and later calls atomically replace the existing file.
To regenerate from the Agent's current configuration before persisting, call `await session.syncshot()` followed by `await session.snapshot()`. If `instruction.md` already exists, `syncshot()` also overwrites it directly.
This method is available only on local `AgentSession` instances. A remote Session's instruction persistence policy belongs to its server host.
### Embed in a Node App
Source: https://downcity.ai/en/agent-sdk-docs/integration-patterns/embed-in-node-app/
This is the most natural SDK usage pattern:
- your application code creates `Agent` directly
- the app prepares a session at startup
- a handler turns incoming requests into `session.prompt()`
Good fit for:
- internal tools
- MCP-style wrappers
- project-oriented engineering assistant hosts
Its biggest advantages are:
- full local context
- direct debugging
- no cross-process networking problems to solve first
## Packaging behavior
The built-in system prompts and init templates in `@downcity/agent` are now compiled directly into TypeScript modules.
That means:
- you do not need to copy extra runtime text assets such as `PROMPT.txt`
- embedding the SDK into Electron main or another Node host no longer depends on preserving a prompt file directory structure
- as long as your runtime can load the ESM entry of `@downcity/agent`, prompt asset path issues will not block startup
### Agent and Federation AIService
Source: https://downcity.ai/en/agent-sdk-docs/integration-patterns/agent-with-city-ai-service/
If you are using Agent inside a normal Downcity project, the model usually should not be owned directly by the Agent project.
Split responsibilities across four layers:
- `Federation AIService`: owns the model catalog, AIChannels, API keys, and model routing
- `Downcity`: connects to City, reads the catalog, validates and resolves `modelId`
- `Agent project`: stores only `execution.modelId`
- `@downcity/agent` session: actually executes prompts
## The most important takeaway
In Downcity integration mode:
- the CLI global database stores a `modelId`
- not AIChannel config
- not API keys
- not a local model instance
The right mental model is:
```text
global downcity.db: agent.execution.modelId
-> Downcity reads and validates it
-> City SDK creates an executable CityModel
-> Agent calls CityModel.stream() through Downcity Model Protocol
-> Federation AIService routes to the AIChannel and upstream protocol
```
### Multiple Agents in One Repo
Source: https://downcity.ai/en/agent-sdk-docs/integration-patterns/multi-agent-in-one-repo/
If you create several local SDK Agents inside the same project directory, the most important rule is:
- give each agent a stable and different `id`
For example:
```ts
const planner = new Agent({ id: "planner" });
const planner_workspace = new Workspace({ id: "planner-project", path: repo_path });
const planner_session = await planner.sessions.create({ workspace: planner_workspace });
const reviewer = new Agent({ id: "reviewer" });
const reviewer_workspace = new Workspace({ id: "reviewer-project", path: repo_path });
const reviewer_session = await reviewer.sessions.create({ workspace: reviewer_workspace });
```
Both Workspaces may target the same physical directory. Session storage remains partitioned by `agent_id`, while each Session explicitly selects its Workspace. If the Agents need isolated command environments, create and pass a separate Shell instance to each Workspace.
## Coordinate a Group with a decision model
A Group selects the members who should respond in the current round and runs them in parallel. Once all selected members finish, their replies enter the shared context for another decision. An empty member set returns the Group to idle. The dispatcher does not generate assignment text or stage plans.
Each GroupSession has one dispatch loop. User input received during execution is saved immediately and joins the whole completed round in the next `pending_messages` batch. Input received during a decision cancels that decision and triggers a fresh one; a stale result cannot start members even if it arrives late. Its audit status is `superseded`, not a failure. Ordinary messages do not interrupt members already executing; use stop to interrupt immediately.
Recovery checkpoints contain unconsumed `pending_message_ids`, including inputs awaiting the end of an executing round. After restart, these messages return to the same loop for a fresh decision.
```ts
/** Select parallel responders through Federation Jev. */
import { Group } from "@downcity/agent";
import { Embassy } from "@downcity/federation";
const embassy = new Embassy({
federation_url: "http://localhost:3000",
user_token: process.env.FEDERATION_USER_TOKEN,
});
const group = new Group({
id: "delivery",
instruction: "Research and implement the solution without repeating completed work.",
members: [planner, reviewer],
model: {
decision: (input, signal) => embassy.user.ai.decision({
...input,
model: "jev-latest",
}, signal),
},
});
const group_session = await group.sessions.create({ workspace: planner_workspace });
await group_session.prompt({ query: "Analyze the current design and suggest actionable improvements." });
```
`group.sessions.create({ workspace })` creates an independent project conversation. Use `group.sessions.self({ workspace })` for the Group's fixed self conversation: its ID always equals `group.id`; the first call creates it and subsequent calls restore it. The host supplies a stable Workspace and must use the same Workspace when restoring. The default session store is in memory; recovery across processes requires a persistent storage provider.
In Desktop, selecting a Group in Chat opens its fixed self conversation in a private `groups//main` Workspace. Project conversations are created independently in Work. The self conversation uses the Group name as its title, exposes configuration through the chat settings menu, and contributes unread attention to Chat.
Configure an execution model and capability description for each member. One decision call submits a boolean question for every member; the Group collects answers into `member_ids`. A custom `dispatch_strategy.decide_dispatch()` also returns only `{ member_ids: [...] }`; an empty array ends the responses. For new user messages, the default decision prompt allows greetings, availability checks, mentions, and direct questions to receive replies. Each member is sampled independently using its reply probability: at or below 40% it does not respond; between 40% and 75% its response rate is `(reply_probability - 0.4) / 0.35`; at or above 75% it always responds. A 60% reply probability therefore gives approximately a 57% response rate. Sampling can still select zero or multiple members. Decision answers must include a `probability` in the 0–1 range, representing confidence in the selected boolean value. For `value: false`, the reply probability is `1 - probability`; missing or invalid probabilities fail the dispatch. Automatic rounds after member replies retain boolean selection and require necessary follow-up work or information, avoiding repeated acknowledgements. Execution failures are recorded in the conversation. Stopping cancels the decision and member executions, and automatic rounds remain bounded by the round limit and repeated-content detection.
Each round supplies members with the actual Group roster, trigger, latest user request, and new messages. User input received during execution becomes the focus of the next round instead of being displaced by a completed member reply. The latest user request takes priority over the long-term Group objective. Greetings, availability checks, and role questions call for brief replies and do not authorize creating documents or starting development. Automatic rounds default to stopping and continue only for explicitly unfinished user-authorized work, necessary handoffs, or questions for a member. General invitations, plans, and requests for user requirements do not justify replying to oneself.
The default strategy saves each member's model value, confidence, reply probability, selection probability, random value (only when sampling), and final selection in `decision.member_decisions` in the dispatch record. These records distinguish model decisions from random sampling when a member does not respond.
Desktop displays a Decision models section. Choose Jev as the Dispatch model when creating or editing a Group. Existing Groups configured with text models must select a decision model. AI-assisted team drafting continues to use a text model, separately from runtime dispatch.
### Expose Agent over HTTP
Source: https://downcity.ai/en/agent-sdk-docs/integration-patterns/expose-over-http/
If the caller and the executor do not live in the same process, you can expose the local Agent as an HTTP service.
In Downcity, HTTP exposure is published by Downcity:
```bash
downcity on
```
The CLI City exposes every registered Agent and Workspace at
`http://127.0.0.1:5314/agents//workspaces/`.
For local process-to-process access, prefer `rpc://`. Use HTTP when an external client needs to call the Agent through the Downcity gateway.
### SDK Surface
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/sdk-surface/
`@downcity/agent` exports Agent and Session execution APIs. Workspace, Power, and transport ownership
belongs to `@downcity/city`.
## Layer 1: everyday core
Most local applications need only:
- `Workspace`: create the project resource and security boundary and receive Shell.
- `Agent`: compose identity, model, instructions, Tools, and Sessions.
- `City`: own Workspaces, unique Power instances, Agent/Group collections, and transports.
- `agent.sessions.create({ workspace })` / `get()`: create or restore a Session in the current directory.
- `agent.session()`: open the conversation that belongs to the agent itself.
- `session.prompt()`: start one Turn.
- `turn.finished`: await the final result.
- `agent.dispose()`: release the Agent's Sessions and detach it from its current City.
```ts
const workspace = new Workspace({ id: workspace_id, path, shell });
const agent = new Agent({ id, model });
// Session 在创建时选择 Workspace。
try {
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Inspect the project" });
const result = await turn.finished;
} finally {
await agent.dispose();
}
```
The local SDK is session-first: Agent performs composition, while Session owns interaction and execution state.
## Layer 2: common advanced APIs
### Agent and Workspace
- `agent.set_instruction()`: update static Agent instructions.
- `city.powers`: add or remove Power instances and call host-management Actions.
- `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })`: inspect or call Powers in an explicit Agent/Workspace execution scope.
- `workspace.get_env()` / `set_env()` / `patch_env()`: manage the project execution environment.
- `agent.get_logger()`: access Agent-level logs; Shell is read from the selected Workspace.
### Session
- `get_info()`: read Session state.
- `subscribe()`: subscribe to live Mutations.
- `messages()`: load message snapshots.
- `stop()`: stop the current execution.
- `system()`: load the current system.
- `interactions()` / `respond()`: handle approvals, questions, and other user interactions.
- `fork()`: branch from an existing Session.
- `snapshot()` / `syncshot()`: manage local Session system snapshots.
- `set({ model })`: override the model for one local Session.
For a live UI, load initial Session and Message snapshots before calling `subscribe()`. Reload snapshots after reconnecting; the event stream is not a complete database.
## Layer 3: framework integration
Use these capabilities only when building powers, servers, or custom runtime components:
- `CityRuntime` from `@downcity/type`: the minimal cross-package City contract used when embedding Agent in a custom composition root. The concrete `City` already implements it.
- `PowerContext`: the restricted City projection for the current Agent/Workspace/Session/Turn, exported by `@downcity/city/power`.
- `SessionTurnContext`: the execution context that coordinates identity, lifecycle, steps, input, and output for one Turn; powers receive only its read-only `PowerExecutionContext` projection.
- `create_session_turn_context(...)`: the standard factory used by custom executors or runtime components to create that Turn Context.
- `SessionExecutor` and `SessionExecutorPort`: the default executor for model requests and the tool loop, plus the port `SessionLoop` accepts when you inject your own.
- Interfaces such as `AgentSession`: replace or extend default implementations.
- RPC/HTTP types: implement a custom transport or protocol integration.
Network server implementations belong to `@downcity/agent`. Local `Agent` does not listen on ports or manage transport lifecycle.
## Local and remote
| Capability | Local `Agent` | `RemoteAgent` |
| --- | --- | --- |
| Create, get, and list Sessions | Yes | Yes |
| Prompt, subscribe, messages, stop, approvals, and fork | Yes | Yes |
| Inject Workspace, Shell, and Tools | Yes | No |
| Bind Powers through City | Yes | Server-owned |
| Set the Agent default model | Yes | No |
| `session.set({ model })` | Yes | No |
| `session.set({ security })` / `session.status()` | Yes | Yes |
| `snapshot()` / `syncshot()` | Yes | No |
| Manage the server transport | Not responsible | Connects only |
The server host always owns the model and system persistence policy for a remote Session.
## Lifecycle rules
```text
one Agent → multiple `agent.sessions.create({ workspace })` Sessions → one agent.dispose()
```
- Agent is not bound to one Workspace; one Agent can enter multiple Workspaces.
- `AgentSessions` owns the Agent's Sessions. Each Session receives its own Workspace context and Power projection.
- `agent.dispose()` releases Session resources and its current City binding; City closes shared Power instances and lifecycle.
- `RemoteAgent.close()` closes only the client connection, not the server-side Agent.
Continue with the [Agent API](/en/agent-sdk-docs/api-reference/agent-class) and [Sessions overview](/en/agent-sdk-docs/sessions/overview).
### Agent Class
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/agent-class/
`Agent` is the reusable execution subject of the local SDK. It owns a stable ID, visible name, capability description, instruction, model, custom Tools, and its Session collection. It owns neither Powers nor a Workspace; `chat()` exposes the conversation that belongs to the agent itself.
## Public API
- `new Agent(options)`
- `agent.id`
- `agent.name`
- `agent.description`
- `agent.workspace`
- `agent.session({ workspace? })`
- `agent.sessions.create({ workspace })`
- `agent.sessions.get(session_id, origin_type?, { workspace? })`
- `agent.set_instruction(input)`
- `agent.get_instructions()`
- `agent.reconfigure(input)`
- `agent.get_logger()`
- `agent.dispose()`
The agent itself is a session; get it with `session()`. Every session, including that one, is created and restored through `agent.sessions`:
```ts
const session = await agent.session({ workspace });
const turn = await session.prompt({ query: "How is this repository structured?" });
const work = await agent.sessions.create({ workspace });
```
A session writes configuration with `set` and reads state with `get`, sharing one handle:
```ts
await session.set({ model, workspace }); // write
session.get("workspace"); // runtime instance
session.get("workspace_id"); // persisted identifier
```
## Reconfiguring an Agent in place
`reconfigure` updates identity and defaults without replacing the instance. Omitted fields keep their current value:
```ts
agent.reconfigure({
name: "Writing assistant",
instruction: ["Outline first, then draft."],
model: next_model,
});
```
The difference from rebuilding the Agent is that execution state survives: the instance, its Session collection,
and the running Turn all stay, so configuration can be saved while a session is still working. Changes apply to
subsequent reads:
| Field | Takes effect |
| --- | --- |
| `name`, `description` | Immediately, including the tool-call context, Power context, Group prompts, and system identity |
| `instruction` | Immediately for new Sessions; created Sessions need an explicit `syncshot()` |
| `model` | The next Step; a Session-level model override still wins |
A running Turn always finishes with the system and model snapshotted when it started. A created Session freezes
its system on first assembly, so a host that wants it to see a new instruction should call `session.syncshot()`
on idle sessions. Name and description are not part of that snapshot; they are read live in every call context.
## Model-facing Agent Power
Once an agent joins a City, its Sessions can use the `agent` Power:
- `agent.list`: list City agents with their `agent_id`, name, responsibility description, default model, and whether they are the current agent.
- `agent.create`: create a persistent agent from `name`, `description`, `instruction`, and `model_id`. Execution requires user approval. The new agent becomes discoverable through `agent.list` after creation.
Desktop and CLI hosts save the definition, assemble the model, and register the agent. Custom City hosts provide this capability through `runtime.create_agent`; the callback returns an agent that has been persisted and registered in the current City, and rolls back on failure.
Use the separate `session` Tool to start work with a target agent or continue a conversation. See [Managing Sessions](/en/agent-sdk-docs/sessions/session-management).
## Constructor options
- `id`: stable Agent identity and private data partition key.
- `name`: optional user-visible name; defaults to `id`.
- `description`: optional concise capability profile used for display and semantic Group dispatch.
- `tools`: custom Tools available to Sessions created by this Agent.
- `callbacks`: request-completion handler registry. Select a handler with `prompt({ request_id, query, callback: { name, data } })` for durable delivery after execution. See [Managing Sessions](/en/agent-sdk-docs/sessions/session-management).
- `instruction`: static Agent-level base instruction.
- `model`: default `ModelClient`; a local Session may override it.
- `workspace`: optional default directory for new sessions; not a binding, and switchable per session.
- `session_class`: optional custom local Session class.
`workspace` only sets the default directory for new sessions. It does not affect the agent's identity
and does not restrict which directories the agent may use later; when omitted, new sessions have no
project context. Each session picks its own directory through `session.set({ workspace })` or the
`{ workspace }` option at creation. See [Agent chat](/en/agent-sdk-docs/sessions/agent-chat) and
[Managing Sessions](/en/agent-sdk-docs/sessions/session-management).
One Agent may work across multiple Workspaces. City provides Powers, and every City Power is
automatically available to every City Agent. Callers create an explicit execution view with
`city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })`. `agent.dispose()` releases Sessions and the current City binding. Network
transports remain owned by the host.
### RemoteAgent Class
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/remote-agent-class/
`RemoteAgent` exposes the following main public API:
- `new RemoteAgent({ url })`
- `agent.sessions`
- `agent.run_power_action(input)`
`agent.sessions.create()` and `agent.sessions.get()` return `RemoteAgentSession`.
## Sessions
`agent.sessions` returns an `AgentSessions` with the following methods:
- `agent.sessions.create(input?)`
- `agent.sessions.get(session_id, origin_type = "work")`
- `agent.sessions.list(input?)`
- `agent.sessions.archive({ id, origin_type? })`
- `agent.sessions.archived(input?)`
- `agent.sessions.clean_archive()`
## `run_power_action`
Use `run_power_action()` when your client UI needs to call an action on the remote runtime.
```ts
await agent.run_power_action({
power: "example",
action: "refresh",
payload: {
scope: "current-session",
},
});
```
Shell approvals belong to the specific remote Session, not the top-level `RemoteAgent`:
```ts
const interactions = await session.interactions();
await session.respond({
interaction_id: interactions[0].request.interaction_id,
response: { type: "approval", outcome: "resolved", payload: { decision: "approved" } },
});
```
You can also handle a pending Interaction through the unified Mutation stream:
```ts
session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
```
## Browser and Chrome extension entry
Use the browser entry for HTTP/HTTPS without bundling Node networking or filesystem modules:
```ts
import { RemoteCity, type PromptPower } from "@downcity/city/browser";
const city = new RemoteCity({ url: "http://127.0.0.1:5314" });
const agents = await city.agents.list();
const agent = await city.agents.get(agents[0].id);
const session = await agent.sessions.create({ origin: { type: "chrome" } });
```
Agent discovery is filtered by the supplied credentials, and `agents.get()` verifies access again. RemoteCity currently supports HTTP/HTTPS and is also exported from the Node entry. Callers pass the City root URL without building Agent paths. Call `await city.close()` to release owned subscriptions and execution channels. Keep the caller page or extension panel open until the Turn finishes so it can execute its Powers.
## Request-level Powers
Supply caller-owned Powers directly to `session.prompt()`. The SDK manages their execution channel and cleanup for that Turn.
```ts
import type { PromptPower } from "@downcity/agent";
const browser_power: PromptPower = {
name: "browser",
description: "Read the caller's current page",
actions: {
read_page: {
access: "read",
input_schema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async (_input, context) => {
context.abort_signal.throwIfAborted();
return { title: document.title };
},
},
},
};
const turn = await session.prompt({
query: "Analyze the current page",
powers: [browser_power],
});
const result = await turn.finished;
```
This example runs in a browser caller. Functions stay in that caller; the server receives serializable descriptions and forwards Action requests. Local Sessions, HTTP/HTTPS and RPC support the same Prompt shape.
Powers apply only to the current Turn. Later prompts, forks and delegated Sessions do not inherit them. Duplicate tool names are rejected. A nonempty `powers` array can only be installed when starting a Turn, not replaced by follow-up inputs. Every prompt requires a trusted host-bound `actor`; a different actor receives busy until the Turn ends. Actions return JSON values; files, runtime handles and cross-host messages are not supported. The default Action timeout is 60 seconds; `timeout_ms` can increase it to at most 5 minutes.
Use `context.call_id` for business idempotency and `context.abort_signal` for cooperative cancellation. Cancellation cannot undo external effects. A disconnected execution channel interrupts the Turn without automatically replaying writes. Reusing `request_id` with different input or Power descriptions is rejected; an interrupted execution cannot be taken over by resubmission.
To export an existing City Power, use `export_prompt_power(power, host, resolve_site, instruction?)` from `@downcity/city/power`. The caller supplies its own runtime and maps remote identities to locally authorized resources. City lifecycle, hooks, HTTP routes and UI registrations are not exported.
## `url`
The current constructor shape is:
```ts
new RemoteAgent({
url: "http://127.0.0.1:5314",
});
```
not `baseUrl`.
`url` currently supports:
- `http://...`
- `https://...`
- `rpc://...`
## Core semantics
It does not execute tasks by itself. It only connects to remote session capability.
If the local `Agent` is the execution shell on the server side, `RemoteAgent` is the paired client.
### Session Types and Results
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/session-types/
Import cross-package data protocols such as `SessionMessage`, `SessionMutation`, `SessionOrigin`,
interactions, and JSON values from `@downcity/type`. Import executable Agent contracts such as
`AgentSession` and Turn handles from `@downcity/agent`.
The most common input and output objects are:
- `AgentSession`
- `RemoteAgentSession`
- `AgentSessionSetInput`
- `AgentSessionSetOptions`
- `RemoteSessionSetInput`
- `AgentSessionStatus`
- `AgentSessionPromptInput`
- `AgentSessionTurnHandle`
- `AgentSessionTurnResult`
- `SessionMutation`
- `SessionMessage`
- `SessionInteractionRequest`
- `RespondSessionInteractionInput`
- `AgentSessionInfo`
## Choose types by job
| What you are building | Object to consume |
| --- | --- |
| Initial load, history, and reconnect recovery | `SessionMessagePage` / `SessionMessage` |
| Live text, tools, and title | `SessionMutation` |
| Waiting for one execution to finish | `AgentSessionTurnHandle` / `AgentSessionTurnResult` |
| Displaying or responding to user interaction | `SessionInteractionRequest` / `SessionInteractionResult` |
| Sidebar and selected Session header | `AgentSessionSummary` / `AgentSessionInfo` |
| Runtime and security state | `AgentSessionStatus` |
`AgentSessionStatus.security` exposes both the configured `approval_mode` and the checkpoint-committed
`effective_approval_mode`. When they differ, the configuration is still waiting in the Session Queue.
`AgentSessionSetOptions` controls observability after a real configuration change commits. `persist_action` decides whether to write an Agent Message containing an Action Part, while `publish_mutation` decides whether to publish its Message Mutation to Session subscribers. Omitting both preserves the default persistence and publication behavior.
## `AgentSessionPromptInput`
Core fields:
- `query`: new text or content parts.
- `request_id`: optional stable identifier for idempotent submission.
- `visibility`: `"visible"` (default) or `"internal"`. Internal inputs are persisted, enter model context, and trigger execution, but are omitted from ordinary history and chat presentation. They retain the existing user input role and instruction priority.
Delegation results use `visibility: "internal"` so runtime notifications do not appear as user messages. Hosts consuming message subscriptions must filter internal messages before rendering.
## `AgentSessionTurnHandle`
Core fields:
- `id`
- `result`
- `finished`
## `AgentSessionTurnResult`
Core fields:
- `turn_id`
- `success`
- `error`
- `text`
Use `text` for a compact final result. There is one canonical structured Assistant Message;
read it through `session.messages()` and `session.subscribe()` instead of a duplicate Turn result.
## Further reading
- [Prompts and Turns](/en/agent-sdk-docs/sessions/prompt-and-turns): input shapes, steering, and queue semantics.
- [Messages and Parts](/en/agent-sdk-docs/sessions/messages): persisted linear history, revisions, and pagination.
- [Live subscriptions](/en/agent-sdk-docs/sessions/subscribe): eight Mutation variants and client merge rules.
- [Asynchronous user interactions](/en/agent-sdk-docs/sessions/approvals): approvals, questions, and responses.
- [Session details](/en/agent-sdk-docs/sessions/info): summary, title, and execution-state fields.
### Session Mutations
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/session-events/
`session.subscribe()` delivers one unified `SessionMutation` stream for Message data, streaming output, Turn lifecycle, and Session state.
```ts
session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
process.stdout.write(mutation.delta);
}
});
```
The seven variants are `message`, `part`, `delta`, `turn`, `file_diff`, `session`, and `warning`. A `warning / model_request` Mutation reports a failed model call and whether another retry will follow. Delta types are `text`, `reasoning`, and `tool_input`; the last one also carries `tool_call_id`. All Mutations contain `mutation_id`, `session_id`, and `created_at`. Message-related Mutations also carry `message_id` and `revision` for safe snapshot updates.
A pending Interaction lives on its Tool Part and carries the complete request while that Tool is `waiting-user`. The subscription remains a data-only channel; submit a user response through the Session command API:
```ts
session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
await session.respond({
interaction_id,
response: { type: "approval", outcome: "resolved", payload: { decision: "approved" } },
});
```
Mutations are a live protocol, not the history format. Use `messages()` for Message state and `get_info()` for Session state. Reload snapshots and subscribe again after a disconnect. For the complete field table, state machine, and loss-free sync algorithm, see [Live subscriptions](/en/agent-sdk-docs/sessions/subscribe) and [Reconnect and sync](/en/agent-sdk-docs/sessions/reconnect-and-sync).
### SDK HTTP Endpoints
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/sdk-http-endpoints/
These SDK routes do not belong to `Agent` itself.
They are exposed by the City HTTP transport returned from `city.http()`. City may start the
standalone server itself or mount its router in a host server such as the Downcity gateway.
The current core set includes:
- `GET /api/sdk/sessions`
- `POST /api/sdk/sessions`
- `GET /api/sdk/sessions/:session_id`
- `POST /api/sdk/sessions/:session_id/prompt`
- `GET /api/sdk/sessions/:session_id/events`
- `GET /api/sdk/sessions/:session_id/messages`
- `GET /api/sdk/sessions/:session_id/system`
- `GET /api/sdk/sessions/:session_id/interactions`
- `GET /api/sdk/sessions/:session_id/status`
- `POST /api/sdk/sessions/:session_id/set`
- `POST /api/sdk/sessions/:session_id/respond`
- `POST /api/sdk/sessions/:session_id/fork`
- `POST /api/sdk/sessions/:session_id/archive`
- `GET` / `DELETE /api/sdk/archived-sessions`
- `POST /api/powers/action`
### City RPC
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/agent-rpc/
RPC is owned by `City`, not by an individual `Agent`. Use `city.rpc()` to expose every
registered Agent through one NDJSON-over-TCP listener.
```ts
const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 });
console.log(rpc.binding()?.url);
await city.close();
```
Remote clients use the route `rpc://host:port//`. See [City transport](/en/city-sdk-docs/en/packages/city/transport)
for the complete composition example.
### City HTTP
Source: https://downcity.ai/en/agent-sdk-docs/api-reference/agent-http/
HTTP is owned by `City`, not by an individual `Agent`. Use `city.http()` to expose every
registered Agent through one listener.
```ts
const http = await city.http({ host: "127.0.0.1", port: 5314 });
console.log(http.binding()?.url);
await city.close();
```
Requests use the route prefix
`/agents//workspaces/`. See [City transport](/en/city-sdk-docs/en/packages/city/transport)
for the complete composition example.
### Example: Minimal Local Agent
Source: https://downcity.ai/en/agent-sdk-docs/examples/minimal-local-agent/
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
const agent = new Agent({
id: "repo-helper",
tools: {},
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
```
Then:
```ts
const session = await agent.sessions.create({ workspace });
await session.set({ model });
const turn = await session.prompt({ query: "Continue" });
await turn.finished;
```
This minimal example is useful for validating three things first:
- whether `Agent` can be constructed successfully
- whether `Session` can be created successfully
- whether the model can execute successfully
### Example: Minimal RemoteAgent
Source: https://downcity.ai/en/agent-sdk-docs/examples/remote-agent-basic/
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "http://127.0.0.1:5314/agents/repo-helper/workspaces/project",
});
const session = await agent.sessions.create();
const turn = await session.prompt({
query: "Summarize this project",
});
const result = await turn.finished;
```
### Example: Live UI
Source: https://downcity.ai/en/agent-sdk-docs/examples/live-ui-example/
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant === "delta" && mutation.type === "text") {
render(mutation.delta);
}
});
const turn = await session.prompt({
query: "Continue",
});
await turn.finished;
unsubscribe();
```
If you also want to show tool activity, handle:
- `part` Mutations for Agent text, tool, interaction, action, and error Parts
- `message` Mutations for complete User and Agent Message snapshots
- `turn` Mutations for lifecycle state
- `session` Mutations for Session state such as title
This pattern is especially good for terminal live output and web-based real-time chat UI.
### SDK Session layout
Source: https://downcity.ai/en/agent-sdk-docs/storage/sdk-session-layout/
A local SDK Agent stores Sessions under the City-provided storage root, usually `~/.downcity`:
```text
~/.downcity/agents//sessions///
├── session.db
└── attachments/
```
A Session is identified by `(origin.type, session_id)`; the default partition is `chat`. Other non-empty
origin types are safely encoded as one directory segment. The project directory never receives a `.downcity`
directory.
Core `session.db` tables:
- `session_state`: identity, Workspace ownership, title, model label, timestamps, and system snapshot.
- `messages`: the User/Agent Message envelope.
- `message_parts`: each Part and its content, ordered by sequence.
A Composer Policy may maintain its own `composer__*` derived tables in the same database. Derived
tables can be rebuilt without changing canonical Messages. Use `session.messages()` to read history; do not
write these files or tables directly.
See [Session metadata and storage](/en/agent-sdk-docs/sessions/metadata-and-storage) for the full design.
### SDK FAQ
Source: https://downcity.ai/en/agent-sdk-docs/faq/sdk-faq/
## Why does a local session need `set({ model })` first?
Because the local SDK Agent owns the execution shell and session capability, while the caller explicitly provides the live model instance.
## Why can RemoteAgent not pass a local model instance remotely?
Because model instances are usually not serializable and should be held on the server side.
## Why is local SDK model configuration different from project runtime model configuration?
Because they target different use cases:
- project runtime is for long-running project execution and gets its model from `execution.modelId`
- local SDK is for embedded use and gets its live model instance explicitly from the caller
### SDK Session Has No Model
Source: https://downcity.ai/en/agent-sdk-docs/troubleshooting/sdk-session-has-no-model/
This is one of the most common local SDK problems.
## Reason
The local SDK has no executable model only when neither the Agent nor the Session provides one.
You can fix it in either way:
```ts
const agent = new Agent({
id: "repo-helper",
model,
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
```
or:
```ts
await session.set({ model });
```
## Correct order
```ts
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "Continue" });
await turn.finished;
```
If you already confirmed that local `Agent` construction succeeded but execution still fails, this is usually the first thing to check.
## What about remote sessions
Remote sessions do not currently support passing a local model instance from the client.
### Model request stops making progress
Source: https://downcity.ai/en/agent-sdk-docs/troubleshooting/model-request-progress/
When a tool appears to remain active, inspect the Agent phase logs. Generating tool arguments through the model stream and executing the tool are separate stages.
## Locate the wait
The Session executor records these boundaries:
| Log | Meaning |
| --- | --- |
| `[agent] step.input_start` | Committing pending input and assembling the step |
| `[agent] step.input_ready` | Input is ready; includes model ID, context window, message count, and tool count |
| `[agent] model_request.request_start` | Starting one model invocation |
| `[agent] model_request.stream_open` | The model client returned an event stream |
| `[agent] model_request.first_event` | First event consumed; this may only be `model_start`, not generated content |
| `[agent] model_request.waiting` | Current stage and latest event, recorded every 30 seconds while the call remains active |
| `[agent] model_request.stream_end` | Stream closed and protocol validation passed; tools may still be executing |
| `[agent] model_request.request_failed` | Invocation or event handling failed; consult failure logs for the cause |
| `[agent] step.finish` | Model step and its tools finished |
Filter by `session_id` and `turn_id`, then use `invocation_id` to identify each model call. `attempt` counts attempts within the same step.
In waiting records, `stage` distinguishes `opening` (waiting for the model client to return a stream), `reading` (waiting for the next event), and `accepting` (processing an event and its output callback). `last_event_type`, `last_event_at`, and `event_count` show whether events are still arriving. Records do not include message bodies or tool arguments.
If `input_start` has no matching `input_ready`, inspect pending input commits and context assembly. If `stream_end` has no subsequent `step.finish`, inspect tool execution and step finalization.
## Check the context window
Inspect `context_window` in input-ready or model-request records. `null` means the execution has no valid window size and cannot calculate context pressure. Check the model definition and catalog metadata propagation instead of inferring capacity from the model name.
With a valid window, the current pressure threshold is 95%. A long context without a compaction record does not by itself indicate a compaction failure. These logs provide diagnostics; they do not add request timeouts, retry partial output, or recover active sessions.
Rebuild the runtime and restart its host to enable logs for new calls. Requests already in progress will not receive historical phase records.
### Agent SDK
Source: https://downcity.ai/zh/agent-sdk-docs/
`@downcity/agent` 提供本地 `Agent` 执行运行时;`@downcity/city` 提供组合根、Workspace/Power
资源、transport 与 `RemoteAgent`。两种客户端形态都以 Session 为主要工作对象:Agent 组合执行
能力,Session 承载持续对话及其执行状态。
## 一分钟心智模型
```mermaid
flowchart LR
Host["SDK 宿主"] --> City["City
资源与 Power 生命周期"]
City --> Agent["Agent
身份与执行"]
Agent --> Workspace["agent.session() / agent.sessions.create({ workspace })
带当前目录的 Session"]
Workspace --> Session["Session
有状态执行容器"]
Session --> Turn["Turn
一次 prompt 执行"]
City --> Power["Power
扩展能力"]
```
| 对象 | 负责 | 不负责 |
| --- | --- | --- |
| `Workspace` | 项目文件、Env、Tools 与可选 Shell | Agent 身份、Power、Session 持久化与 Sandbox 实现 |
| `Shell` | Sandbox Provider、Workspace 持久 Sandbox 与 Shell Sessions | Chat Session 身份与 City 生命周期 |
| `Agent` | 模型、指令、自定义 Tool 与 Session | Power 实例、Workspace 资源、HTTP/RPC transport |
| `AgentSessions` | 当前 Agent 的全部 Session;每个 Session 可以接收一个 Workspace | Agent 身份与 Power 定义 |
| `Session` | 对话状态、消息历史、执行顺序与审批 | 项目资源所有权 |
| `City` | Workspace 与 Power 资源、生命周期、Storage 和 transport | Session 执行语义 |
| `SessionTurnContext` | 一个 Turn 的执行上下文与生命周期 | 保存长期 Agent 状态 |
| `RemoteAgent` | 访问服务端 Agent 与 Session | 配置服务端模型 |
## 选择本地还是远程
| 场景 | 使用 |
| --- | --- |
| Agent 与应用运行在同一个 Node.js 进程 | `Agent` |
| 应用需要直接注入模型、Tool、Power 和 Shell | `Agent` |
| Agent 已由另一个进程通过 HTTP/RPC 暴露 | `RemoteAgent` |
| 客户端只需要创建 Session、发送 prompt 和订阅结果 | `RemoteAgent` |
网络服务不属于 Agent。创建 `City`,通过 `city.agents.add(agent)` 加入 Agent,再使用
`city.http()` 或 `city.rpc()` 暴露整个 City。
## 推荐的本地组合
Shell 是本地 Agent 使用项目命令和进程的标准入口。下面的嵌入式示例直接把 microsandbox Provider 注入 Shell:
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
import { Shell } from "@downcity/city";
const shell = new Shell({
sandbox_provider: new MicrosandboxProvider(),
});
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell,
runtime_path: "/path/to/downcity-runtime/project",
});
const agent = new Agent({
id: "repo-helper",
model,
});
// Session 在创建时选择 Workspace。
try {
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "总结当前仓库" });
const result = await turn.finished;
console.log(result.text);
} finally {
await agent.dispose();
}
```
只需要记住这条主链路:
```text
Agent + Workspace + Shell → agent.session() / agent.sessions.create({ workspace }) → Session → send() / prompt() → turn.finished → dispose()
```
`agent.dispose()` 会释放 Session 使用的资源并解除当前 City 绑定;City 负责收口 Power 唯一实例、
lifecycle 与 ActionSchedule。项目目录不会创建 `.downcity`。
## 模型归属
纯 SDK 嵌入模式由宿主持有模型实例:
- 在 `new Agent({ model })` 中设置默认模型。
- 或对一个本地 Session 调用 `await session.set({ model })` 覆盖默认模型。
Downcity 项目模式由 CLI 宿主根据项目配置和 City AIService 模型目录解析模型。远程 Session 不提供客户端模型配置 API,模型始终由服务端宿主持有。
## 推荐阅读顺序
1. [本地 Agent 快速开始](/zh/agent-sdk-docs/local-agent/quickstart)
2. [Session 总览](/zh/agent-sdk-docs/sessions/overview)
3. [Shell 与沙箱](/zh/agent-sdk-docs/local-agent/shell-reference)
4. [Tools 与 Powers](/zh/agent-sdk-docs/local-agent/tools-and-powers)
5. [远程 Agent](/zh/agent-sdk-docs/remote-agent/quickstart)
6. [SDK Surface](/zh/agent-sdk-docs/api-reference/sdk-surface)
7. [Agent SDK 架构](/zh/docs/agent/sdk-architecture)
### 本地 Agent 快速开始
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/quickstart/
## 安装
```bash
pnpm add @downcity/agent @downcity/city @downcity/federation @downcity/sandbox-microsandbox
npx microsandbox setup
```
`@downcity/sandbox-microsandbox` 是跨平台 Sandbox Provider,为每个 Workspace 提供独立、持久的 microVM 环境。
## 完整示例
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { create_openai_compatible_model } from "@downcity/federation";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const model = create_openai_compatible_model({
id: "gpt-5",
upstream_model: "gpt-5",
base_url: "https://api.openai.com/v1",
api_key: process.env.OPENAI_API_KEY!,
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const agent = new Agent({ id: "repo-helper", model });
const city = new City({
workspaces: [workspace],
agents: [agent],
});
try {
const session = await agent.sessions.create({ workspace });
const turn = await session.prompt({ query: "总结当前仓库结构" });
console.log((await turn.finished).text);
} finally {
await city.close();
}
```
## 所有权
- City 持有 Power 与登记资源,但不理解 Workspace 的执行后端。
- Workspace 持有项目资源、环境变量与可选 Shell。
- Shell 持有 Sandbox Provider、Workspace 持久 Sandbox 与 Shell Sessions;`shell_id` 与 Chat `session_id` 相互独立。
- Agent 持有 Chat Sessions。Chat Session 只借用选中的 Workspace,不拥有 Shell 或 Sandbox。
Shell 命令默认使用 `target: "sandbox"`,项目固定挂载到 `/workspace`。同一 Workspace 会跨 Chat Session 保留 HOME 和已安装工具。`target: "host"` 必须提供原因并经过审批。Sandbox 启动失败不会降级成宿主执行。
没有 Shell 时,Workspace 仍提供文件与搜索工具。`city.close()` 会释放 Agent、Power 与 Workspace;Workspace 再委托 Shell 停止 Sandbox 计算资源并保留其持久文件系统。
## 下一步
- [Shell 与 Sandbox](/zh/agent-sdk-docs/local-agent/shell-reference)
- [Session 概览](/zh/agent-sdk-docs/sessions/overview)
- [Tools 和 Powers](/zh/agent-sdk-docs/local-agent/tools-and-powers)
### Agent 构造参数
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/constructor-options/
本地 Agent 的构造方式:
```ts
const workspace = new Workspace({
id,
path,
shell,
env,
});
const agent = new Agent({
id,
name,
description,
model,
instruction,
tools,
powers,
Session,
});
// Session 在创建时选择 Workspace。
```
## `id`
agent 的稳定标识。
它会影响用户级 Agent 数据路径:
```text
~/.downcity/agents//...
```
推荐:
- 稳定
- 可 URL 编码
- 不要随便改
因为它直接决定了 session 落盘目录分区。
## `name` 与 `description`
`name` 是 Agent 的用户可见名称;省略或传入空字符串时回退到 `id`。`description` 是一句话能力简介,供宿主展示,也供 Group 调度器根据语义选择成员。
```ts
const reviewer = new Agent({
id: "reviewer",
name: "代码审查员",
description: "负责审查代码质量、测试覆盖和交付风险",
instruction: "审查时给出具体、可执行的修改建议。",
});
```
`id` 与 `name` 会组成每个 Session system prompt 最前面的 Agent 身份块:
```text
# Agent Identity
You are "代码审查员" (agent id: reviewer).
```
两个值总是同时给出:模型用可读名称理解角色,宿主仍可用稳定 `id` 对账。
`description` 不会进入普通 AgentSession 的 system 指令,也不替代 `instruction`:前者描述“这个 Agent 适合做什么”,后者约束“这个 Agent 应该如何工作”。Group 调度只读取成员的 `id/name/description`,不会读取完整 `instruction`。
## `workspace` 与会话目录
当前 Agent 可以访问的项目资源与安全边界。Workspace 统一持有项目根目录、文件与搜索工具,以及可选 Shell:
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell,
});
```
关键点:
- macOS、Linux 和 Windows 使用同一个 `Workspace`
- `path` 会在 Workspace 构造时解析为真实绝对目录
- 文件和搜索工具固定限制在 Workspace 内
- Shell 与文件工具使用同一个项目根目录
- Agent 构造时可以传入 `workspace` 作为新建 Session 的默认目录;它不构成绑定
- 会话的当前目录可以在创建后通过 `session.set({ workspace })` 或在 `get()` 时指定,随时切换
- 同一个 Agent 可以进入多个 Workspace,每个 Session 拥有独立的当前目录、历史、Shell 和 Power 上下文
- 多个 Agent 可以指向同一目录,但使用各自的 `agent.id` 和数据分区
`Workspace` 只需要提供稳定的 `id`、真实项目 `path`,以及可选的 Shell 和 env。Downcity 内部数据根目录由实现自动解析:
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell,
});
// Session 在创建时选择 Workspace。
```
如果 `path` 不存在、不是目录或为空,Workspace 会立即拒绝构造。
本地实现默认把私有数据写入 `~/.downcity`。测试或多实例宿主可以通过 `DC_PLATFORM_ROOT` 覆盖根目录,该环境变量不属于 Workspace API。
## `instruction`
调用方传入的本地 SDK Agent 静态基础指令。
```ts
new Agent({
id: "repo-helper",
instruction: [
"你是一个简洁的代码助手。",
"解释代码时优先给出明确文件引用。",
],
});
```
关键点:
- `instruction` 是静态的,应该保持缓存友好
- SDK 不会对它做动态变量渲染
- SDK 不读取项目 prompt 配置文件
- 如果省略 `instruction`,SDK 会使用包内最小 core instruction 作为 fallback
宿主需要额外基础指令时,应通过 `instruction` 显式传入。
## `model`
Agent 持有的默认 `ModelClient` 实例。可以使用 Federation 目录返回的 `CityModel`,也可以使用进程内 Provider Adapter 创建模型。
```ts
const model = create_openai_compatible_model({
id: "gpt-5",
upstream_model: "gpt-5",
base_url: "https://api.openai.com/v1",
api_key: process.env.OPENAI_API_KEY!,
});
new Agent({
id: "repo-helper",
model,
});
```
关键点:
- SDK 不负责选择、保存或恢复模型 ID,只持有 `ModelClient` 实例
- `CityModel` 自身实现 `ModelClient`,Agent 直接调用 `stream(call, signal)`
- Session 没有自己的模型时回退使用 Agent 模型
- 本地 Session 可通过 `session.set({ model })` 覆盖 Agent 模型
- 有效模型解析顺序固定为 Session 模型、Agent 模型
## Workspace 的 `env`
Env 描述当前 Workspace 的项目执行环境,由 Workspace 持有:
```ts
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "",
},
});
```
关键点:
- SDK 先读取项目 `.env`,再用 Workspace 构造参数 `env` 覆盖同名值
- Env 属于 Workspace,不属于 Agent
- 如果宿主需要合并多层 env,应先在外部合并,再把最终结果传给 `env`
- 这些值会成为 Workspace 的运行时 env,但不会自动回写系统环境变量
### 运行时更新 env
构造 Workspace 之后,可以在运行期增量更新或整体替换 configured env:
```ts
workspace.set_env({ FOO: "1" });
workspace.get_env(); // => { FOO: "1" }
workspace.patch_env({ BAR: "2" }); // 增量合并
workspace.patch_env({ FOO: null }); // null 表示删除
workspace.set_env({ ONLY: "ok" }); // 整体覆盖
```
关键点:
- `get_env()` 返回浅拷贝快照
- `patch_env()` 中 `null` / `undefined` 表示删除该 key
- `set_env()` 整体覆盖当前 Workspace env
- `agent.set_instruction()` 只影响之后新建的 Session,不会回溯修改当前内存中的已有 Session
- Session 恢复时若没有 `instruction.md`,会从 Agent 当前 instruction 和 Power 重新生成 system;已有 Session 可用 `session.syncshot()` 手动刷新,并用 `session.snapshot()` 持久化
- Workspace env 与 Power 修改会在已有 Session 的下一个 Session step 检查点提交
- 正在进行的 provider 请求及其工具调用继续使用当前 step 已生效的 env
- 如果当前 Session turn 没有后续 step,排队中的修改会在下一个 Session turn 开始时提交
- 配置真正生效时,Session timeline 会写入 completed action message
- 运行时变更只活在内存,不会回写项目 `.env` 或系统环境变量
## `tools`
Agent 最终持有的额外自定义工具集合。
关键点:
- Workspace 提供文件、搜索和可选 Shell 组成的 WorkspaceTools
- 宿主为每个 Power 推送一个 AgentTool,工具名即 Power 名
- `AgentOptions.tools` 提供调用方自定义工具
- `ask_question` 等可选工具需要调用方从 `@downcity/agent/tools` 导入并显式传入
- Agent 统一注册这些工具并由 Session 共享
- 任意来源的工具重名会直接报错,不会静默覆盖
这很适合“多个 session 共用一套默认工具能力”的场景。
## Workspace 的 `shell`
详细配置参见 [Shell 与沙箱](/zh/agent-sdk-docs/local-agent/shell-reference)。
可选的内建 shell 能力。
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { Shell } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const agent = new Agent({ id: "repo-helper" });
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
runtime_path: "/path/to/downcity-runtime/project",
});
```
关键点:
- Shell 不是 Power
- Shell 只挂载 `shell_exec` 和 `shell_session`
- Workspace 自身挂载 `grep`、`find`、`read`、`write` 和 `edit`,没有 Shell 时仍可使用
- session 与 turn 上下文由 Agent 内部自动接线
- 审批归属具体 Session,使用 `session.interactions()`、`session.respond(...)` 和 `session.set({ security })`
- 文件和搜索工具固定根植于 Agent 项目目录,不提供 host 执行目标
## Power 不属于 Agent 构造参数
`AgentOptions` 不接收 `powers`。City 登记 `CityPowerRegistration` 后,自动把全部 Power 提供给全部 Agent:
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
const agent = new Agent({ id: "repo-helper", tools: {} });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
City 持有 Power 唯一实例、配置投影与生命周期;Agent 只持有身份、模型、指令、Tools 和 Sessions。
联网能力仍需由宿主配置,请参阅 [Web Power 文档](/zh/powers-docs/builtins/web)。
## `session_composer`
只需要调整模型输入或上下文维护方式时,直接传入一个 Composer 实例。它属于 Agent 级特性,描述「这个 Agent 怎么和模型说话」,被该 Agent 的全部 Session 共享。
```ts
import { Agent, DefaultSessionComposer } from "@downcity/agent";
const agent = new Agent({
id: "repo-helper",
session_composer: new DefaultSessionComposer(),
});
```
因此 Composer 实现必须无状态:具体 Session 与上下文全部由 `compose(input)` 参数传入,派生数据写入传入的 `derived` 存储。不要在实例上缓存任何与 Session 相关的内容。
### Session Composer 的定位
Composer 是唯一回答「这一步模型看到什么」的组件。`canonical history` 与派生存储都由调用方传入,Composer 只读取它们并返回模型输入:
| 方法 | 用途 |
| --- | --- |
| `initialize(input)` | 在当前 Session 的 `session.db` 初始化自己的派生 schema;没有派生状态的实现可以是空实现 |
| `compose(input)` | 返回当前模型 Step 使用的 `system`、`messages` 和 `tools` |
| `advance_context(input)` | 在 `usage_pressure` 或 `provider_context_limit` 触发下尝试推进派生状态;发生有效推进时返回 `true` |
`compose(input)` 可以:
- 增删或重排 system messages
- 筛选、转换或注入本轮模型 messages
- 调整当前 Step 可见的 tools
「压缩」不是独立概念,而是 `advance_context()` 的一种实现。`FullHistorySessionComposer` 就是不推进派生状态的现成实现;自定义压缩算法只需继承 `DefaultSessionComposer` 并覆盖 `advance_context()`。
Composer 不消费 Prompt Queue,不控制 Turn,也不修改 canonical Message。它只能写命名空间为 `composer__*` 的可重建派生表。只有确实要替换 Session 行为时才使用 `session_class`。
### Tools 和 Powers
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/tools-and-powers/
Tool 与 Power 都能参与 Session 执行,但所有权不同:Agent 持有自定义 Tool;City 持有 Power
唯一实例、配置投影与生命周期。
## 自定义 Tools
本地 Agent 直接接收自定义 Tool:
```ts
const agent = new Agent({
id: "repo-helper",
tools: {
my_tool,
},
});
```
这些 Tool 会与当前 Session 所选 Workspace 的 Tool 合并。名称冲突会直接报错,不会静默覆盖。
## Workspace 与 Shell
Workspace 默认提供 `grep`、`find`、`read`、`write` 和 `edit`。需要命令执行时显式配置 Shell:
```ts
import { Agent } from "@downcity/agent";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
import { Shell, Workspace } from "@downcity/city";
const agent = new Agent({ id: "repo-helper" });
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
runtime_path: "/path/to/downcity-runtime/project",
});
```
Shell 额外提供 `shell_exec` 与 `shell_session`。文件和搜索 Tool 始终受 Workspace 根目录约束;审批与异步问题归属于具体 Session,通过 `session.interactions()` 和 `session.respond(...)` 处理。
## City 提供 Powers
Agent 构造函数不接收 Power。把 registration 交给 City 后,所有 Agent 会自动获得它:
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
const agent = new Agent({ id: "repo-helper", model });
const city = new City({
workspaces: [workspace],
powers: create_builtin_power_registrations(),
});
city.agents.add(agent);
```
City 会把全部已注册 Power 自动提供给全部 Agent。运行中的 City 可通过
`city.powers.add()` 与 `city.powers.remove()` 动态修改 Power 集合。
一个 City 中每个 Power ID 只有一个实例,并且只有 City 级 `initialize/dispose` 生命周期。
Agent 与 Workspace 只提供每次调用的上下文,不拥有 Power 生命周期。
## City 级配置
需要配置的 Power 由宿主提供唯一 Config store。配置不进入 Agent,也不决定 Power 实例数量:
```ts
const city = new City({
power_host: {
config: (power_id) => open_power_config_store(power_id),
},
});
```
每个 Power ID 在一个 City 中只有一份配置。多账号结构和选择规则由 Power 自己表达,City
不提供通用命名配置层。配置保存后,Power 负责刷新受影响的长期资源。
## 调用 Power
宿主从 Agent/Workspace 执行入口调用 Action 或 Hook:
```ts
const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({
power: "memory",
action: "search",
payload: { query: "项目约定" },
});
const enriched = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", enriched);
```
Power 声明 Action 时,宿主为它推送一个以 Power 名命名的 AgentTool。模型用 `{ action, args }` 调用;省略 `action` 时返回动作索引及其 schema。Power 的 `system()` 文本在 `session.system_context` 检查点贡献。
## PowerContext 直接通信
Power 作者 API 从 `@downcity/city/power` 导入。Action、Hook、System 和 Availability 获得 `PowerContext`,其中包含受限的 `city`、`agent`、`workspace`、`session`、`turn`、`config` 和 `storage` 句柄。
`context.session` 与 `context.agent.sessions` 都是可直接调用的进程内对象,不需要只保存 `session_id` 再回查宿主。Workspace 文件使用 `context.workspace.files`;Power 私有文件使用 `context.storage.files`。
## HTTP
Power 可声明结构化 HTTP route。City 统一挂载、鉴权并注入请求对应的 PowerContext;Power 不创建独立服务器。
## 相关文档
- [Power 总览](/zh/agent-sdk-docs/powers/overview)
- [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle)
- [Power Actions](/zh/agent-sdk-docs/powers/power-actions)
- [内建 Powers](/zh/powers-docs)
- [用户异步交互](/zh/agent-sdk-docs/sessions/approvals)
### Shell 与 Sandbox
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/shell-reference/
Downcity 将四种职责分开:
```text
City → Workspace
Workspace → Shell
Shell → SandboxProvider + Workspace 持久 Sandbox + Shell Sessions
Chat Session → 只负责调用
```
每个 Workspace 拥有一个独立、持久的 Sandbox。同一 Workspace 下的多个 Chat Session 共享 Sandbox HOME、安装工具和缓存;不同 Workspace 不共享可写 Sandbox 状态。Shell Session 使用 `shell_id` 标识,是独立进程资源,不是 Chat Session。
## 安装与初始化
```bash
pnpm add @downcity/agent @downcity/city @downcity/sandbox-microsandbox
npx microsandbox setup
```
默认 Provider 使用 microsandbox microVM 与 OCI 镜像,支持相应的 macOS、Linux 和 Windows 宿主。
```ts
import { Agent } from "@downcity/agent";
import { City, Shell, Workspace } from "@downcity/city";
import { MicrosandboxProvider } from "@downcity/sandbox-microsandbox";
const workspace = new Workspace({
id: "project",
path: process.cwd(),
shell: new Shell({
sandbox_provider: new MicrosandboxProvider(),
}),
});
const city = new City({
workspaces: [workspace],
});
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
```
不使用 City 的嵌入式宿主,需要向 Shell 提供 `sandbox_provider`,向 Workspace 提供 `runtime_path`。City 只自动分配运行目录,不选择或持有 Provider。
## 执行目标
Shell 只保留两个目标:
- `sandbox`:默认值。命令在 Workspace microVM 中执行,项目固定挂载到 `/workspace`。
- `host`:使用完整宿主权限,必须提供非空原因并经过宿主审批。
```ts
shell_exec({
cmd: "open ./report.pdf",
target: "host",
reason: "在宿主桌面应用中打开生成的报告。",
});
```
系统不提供部分宿主路径白名单、中间权限模式或静默降级。Sandbox 不可用时直接失败,不会切换到宿主执行。
## 路径与 Workspace 挂载
文件与搜索工具在宿主进程中执行,Shell 命令在 Sandbox 内执行。两者都接受沙箱内路径,例如 `/workspace/src/main.ts`:沙箱路径会在路径策略之前映射回项目根,因此 `/workspace/src/main.ts` 与 `src/main.ts` 指向同一个文件,返回结果始终是宿主侧路径。挂载之外的路径(例如 `/etc/hosts`)仍会被拒绝。
## 工具与生命周期
`Shell` 提供适合短命令的 `shell_exec`,以及适合长任务和交互进程的 `shell_session`。后者支持 `start`、`send`、`read`、`wait`、`list` 和 `stop`。
关闭 Workspace 会释放其 Shell;Shell 先关闭全部 Shell Sessions,再停止 Sandbox 计算资源,同时保留 Sandbox 文件系统。使用相同 City storage 恢复同一 Workspace 时会重新连接该环境。只有显式调用 `shell.reset_sandbox()` 才会删除 HOME 和已安装工具。
Workspace 挂载在 Sandbox 创建时固定。如果稳定 Workspace ID 后续指向不同宿主路径或 City runtime path,Downcity 会拒绝连接并要求显式 reset,避免静默挂载意外文件。
## 环境变量
Sandbox 命令只获得显式 Workspace env 与 Downcity 执行标识,不继承完整宿主 `process.env`。Host 命令从宿主环境开始,再叠加 Workspace env。两种执行都会移除 Downcity 鉴权密钥。
`workspace.set_env()` 与 `workspace.patch_env()` 只影响后续命令;已启动进程继续使用创建时的环境快照。
## 失败提示
命令以非零退出码结束或 Shell 状态为 `failed` 时,工具返回 `success: false` 和明确的 `error`,包含退出码(如有)以及最多 2000 个字符的输出尾部。完整的返回输出仍保留在 `output` 中;交互会话的当前输出为空时,错误提示使用最近的输出预览。
审批未通过时返回 `Host execution was not approved.`。这也可能是审批网关或调用身份缺失,不能仅凭这条提示判断用户主动拒绝了请求。
本地 Agent 的 Session ID 不区分大小写,创建和查询入口统一为小写。新建和 fork 使用相同的 12 位随机 ID,例如 `k7m2x9p4w6rt`;字符排除容易混淆的 `0/o`、`1/i/l`。自动生成的 ID 直接作为目录名,存储层仍处理自定义标识中的特殊路径字符和 Windows 保留名称。
旧 Session 需要停机迁移后才能使用新规则。迁移脚本默认只预览:
```bash
node public/packages/agent/scripts/migrate-session-ids.mjs /path/to/agent-root
# 停止 Downcity,并检查预览与外部引用后执行:
node public/packages/agent/scripts/migrate-session-ids.mjs /path/to/agent-root --apply --offline
```
工具检查同一来源分区内的大小写冲突,备份活动和归档目录,再更新数据库身份、消息来源与结构化附件路径并重命名目录。旧 ID 只转小写,不改成短 ID。备份目录中的 `manifest.json` 提供 ID 和路径映射;Task、Messenger、Power 或自定义 Composer 的外部引用由其维护者依据映射同步更新,完成后再启动 Downcity。迁移失败时保持停机,从备份恢复两棵 Session 目录。
## 审批
每次 `host` 启动,以及向 host Shell Session 写 stdin,都会经过当前 Chat Session 注入的审批网关。审批属于当前工具调用,但创建出的 Shell Session 仍是 Workspace Shell 资源。
```ts
const pending = await session.interactions();
await session.respond({
interaction_id: pending[0].request.interaction_id,
response: {
type: "approval",
outcome: "resolved",
payload: { decision: "approved" },
},
});
```
`ask` 是默认审批模式。`always-allow` 只影响当前 Chat Session 后续的新 host 审批请求,不改变 Sandbox 隔离边界。
### 通过 City 暴露 RPC 与 HTTP
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/http-and-rpc/
外部服务属于 `City`。Agent 仍然是执行主体,City 负责网络监听,并把请求路由到对应的
Agent 和 Workspace。
```bash
pnpm add @downcity/agent @downcity/city
```
## 启动 City transport
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({ id: "project", path: process.cwd() });
const agent = new Agent({ id: "assistant", model });
const city = new City({ workspaces: [workspace] });
city.agents.add(agent);
const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 });
const http = await city.http({ host: "127.0.0.1", port: 5314 });
console.log(rpc.binding()?.url); // rpc://127.0.0.1:15314
console.log(http.binding()?.url); // http://127.0.0.1:5314
await city.close();
```
`City` 统一拥有一个 HTTP transport 和一个 RPC transport,按 Agent ID 与 Workspace ID 路由:
- HTTP:`http://127.0.0.1:5314/agents/assistant/workspaces/project`
- RPC:`rpc://127.0.0.1:15314/assistant/project`
RPC 使用 NDJSON 协议。鉴权、CORS 和其他宿主级策略由 City 宿主负责。
## 同时启动两个 transport
```ts
await city.listen({
http: { host: "127.0.0.1", port: 5314 },
rpc: { host: "127.0.0.1", port: 15314 },
});
await city.close();
```
需要独立控制生命周期时,分别调用 `city.http()` 或 `city.rpc()`。
## 与 RemoteAgent 的关系
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "http://127.0.0.1:5314/agents/assistant/workspaces/project",
});
const session = await agent.sessions.create();
```
`RemoteAgent` 只消费 City 暴露的地址,不创建或配置服务端 Agent。
## 相关文档
- [Local Agent 快速开始](/zh/agent-sdk-docs/local-agent/quickstart)
- [RemoteAgent 快速开始](/zh/agent-sdk-docs/remote-agent/quickstart)
- [City transport](/zh/city-sdk-docs/zh/packages/city/transport)
### 例子:在 Node 服务里嵌入本地 Agent
Source: https://downcity.ai/zh/agent-sdk-docs/local-agent/example-node-service/
下面是一个很常见的模式:
1. 你的 Node 服务进程内创建 `Agent`
2. 服务启动时创建或获取 session
3. 某个 HTTP handler 收到请求后转成 `session.prompt()`
这种模式适合:
- 内部工具
- 研发助手
- 把某个仓库能力封装成服务
## 最小嵌入模式
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
const agent = new Agent({ id: "repo-helper", model });
const workspace = new Workspace({
id: "project",
path: process.cwd(),
});
const session = await agent.sessions.create({ workspace });
```
这种模式下,模型实例由你的 Node 宿主直接持有。
## 如果要跨进程复用
如果后面要给别的进程复用,通常有两条路:
- 用 `City` 提供的 `city.rpc()` / `city.http()` 自己暴露
- 或通过 `downcity on` 由 CLI City 暴露 HTTP gateway
然后另一个进程用 `RemoteAgent({ url })` 连接它。
## 如果你是 Downcity 项目
如果你不是在做纯 SDK 嵌入,而是在跑正常的 Downcity Agent 项目,通常不应该在这里直接持有 provider 配置或模型实例。
这时更推荐:
- 在 `Federation AIService` 注册模型
- 在项目里通过 `execution.modelId` 绑定模型
也就是说,Node 宿主嵌入 `Agent` 和通过 Downcity 连接 `Federation AIService`,是两种不同的使用路径。
### Power 总览
Source: https://downcity.ai/zh/agent-sdk-docs/powers/overview/
Power 是 Downcity 的统一扩展产品单元,可以提供 Action、Hook、Resolve、System、Availability、
HTTP Route、生命周期以及可选的 Mainview/Config。
Power 不属于 Agent。City 持有 catalog、main、Power 唯一实例与完整生命周期;全部 Power 自动
提供给全部 Agent,Session 在执行检查点取得稳定的 Hook 视图。
## SDK 装配
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { create_builtin_power_registrations } from "@downcity/powers";
const registrations = create_builtin_power_registrations();
const city = new City({ workspaces: [workspace], powers: registrations });
const agent = new Agent({ id: "repo-helper", model });
city.agents.add(agent);
```
`CityOptions.powers` 或 `city.powers.add()` 负责登记;`city.agents.add()` 只登记 Agent。
## PowerContext
Action、Hook、System 与 Availability 都使用当前作用域的 `PowerContext`。它提供受限的:
- `city`:Embassy 与 Power 间协作端口。
- `agent`:身份、指令和 Session 集合。
- `workspace`:文件、Shell、env 与稳定 ID。
- `session` / `turn`:当前调用存在 Session/Turn 时的直接通信句柄。
- `config`:City 为当前 Power 投影的只读配置快照。
- `storage`:当前 Agent/Power 私有存储。
对象句柄用于进程内直接通信,ID 用于稳定身份和序列化;Power 不需要只拿 `session_id` 再绕回宿主查询。
## 唯一实例与唯一配置
一个 City 中每个 Power ID 只有一个实例和一套 `initialize/dispose` 生命周期。Agent/Workspace
只属于每次调用的 Context。每个 Power ID 也只有一份 City 级配置,所有 Agent 获得相同的
`context.config` 快照。Memory 和 Chat 等 Power 按自己的真实领域所有权隔离长期资源。
## 推荐阅读
1. [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle)
2. [Power Actions](/zh/agent-sdk-docs/powers/power-actions)
3. [Power Hooks](/zh/agent-sdk-docs/powers/power-hooks)
4. [Power 配置](/zh/agent-sdk-docs/powers/configuration)
5. [Power 开发指南](/zh/agent-sdk-docs/powers/power-development)
6. [内建 Power 手册](/zh/powers-docs)
### Power Design Patterns
Source: https://downcity.ai/zh/agent-sdk-docs/powers/design-patterns/
不是每个 Power 都应该长得一样。
在当前 Downcity 里,健康的 Power 设计通常从“先选主形态”开始。
## 1. Action-first Power
适合这些目标:
- 显式用户触发能力
- 明确的命令或 API 入口
- 边界清楚的一次性工作
典型例子:
- `skill`
- `sound`
## 2. Hook / resolve Power
当 Power 需要参与现有运行链路时,使用这种形态。
典型例子:
- `messenger` 的内置授权能力
- `sound` 的自动 ASR
- 各类 role resolver 风格 Power
## 3. System-text Power
当 Power 的主工作是增加稳定运行时指导文本时,使用这种形态。
典型例子:
- `skill`
- `web`
- `memory`
## 4. Lifecycle-owned runtime Power
当 Power 必须拥有长期运行态时,使用这种形态。
典型例子:
- `messenger`
- `task`
- `shell`
ActionSchedule 有意不建模成 Power;它是共享的 Agent action 模块。
它适合这些能力:
- start
- stop
- recover
- 持有内存状态
- 随时间 poll 或 react
## 5. Runtime HTTP Power
当 Power 主要通过 HTTP 暴露状态或控制面时,使用这种形态。
这种形态适合由自定义 Power 提供专用状态端点,同时复用宿主的鉴权与生命周期。
## 一个实用判断
不要先问“这段代码最方便塞在哪里”。
先问:
- 它是不是显式命令
- 它是不是链路参与点
- 它是不是稳定 system 指导
- 它是不是生命周期拥有的 runtime state
- 它是不是 HTTP 暴露面
## 相关文档
- [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle)
- [Power Actions](/zh/agent-sdk-docs/powers/power-actions)
- [Power Hooks](/zh/agent-sdk-docs/powers/power-hooks)
- [Power Runtime Composition](/zh/powers-docs/examples/power-runtime-composition)
### Power 生命周期
Source: https://downcity.ai/zh/agent-sdk-docs/powers/lifecycle/
Power 的所有权属于 City。`city.powers.add(new MyPower())` 登记一个唯一实例;同一 Power ID
不能在一个 City 中重复注册,Agent 也不保存 Power 实例或 Registry。
## 两个生命周期节点
- `initialize(context)`:Power 加入 City 时执行一次,用于注册宿主 Action,并初始化 Power 自己拥有的长期资源。
- `dispose(context)`:Power 被移除或 City 关闭时执行一次,用于释放这些长期资源。
```ts
class ProviderPower extends Power {
readonly name = "provider";
async initialize({ storage }: PowerLifecycleContext) {
await this.provider.open(storage.path);
}
async dispose() {
await this.provider.close();
}
}
```
两个节点使用同一个稳定的 `PowerLifecycleContext`。Agent 加入或离开 City、Session 创建或结束、
Workspace 进入或离开都不会触发 Power 生命周期,也不存在通用的 `connect/disconnect`。
`city.powers.add()` 会明确返回初始化失败。失败实例不会进入执行 Registry,因此不能贡献 Action、Hook
或 System;City 同时在 `city.powers.snapshots()` 中保留 `status: "error"` 和 `last_error`。
宿主应隔离单个 Power 的失败并继续启动其他主体,而不是把可选 Power 当成整个应用的启动条件。成功重试
同一 Power ID 后,错误快照会被新的 `ready` 状态替换。
## 调用上下文
Action、Hook、System 和 Availability 每次调用都会获得 City 即时投影的 `PowerContext`。其中的
`agent`、`workspace`、可选 `session/turn` 都是当前调用可以直接使用的受限对象;Workspace 是本次
调用的项目资源,不是 Power 的生命周期或默认资源隔离键。
需要长期连接、Timer、调度器或后台 Worker 时,Power 应按真实领域所有权管理它们。例如浏览器
Provider 可以在首次 Action 时按 Agent 惰性创建,聊天渠道可以按账号管理,任务调度器
可以按任务定义管理。所有这些资源最终都必须由 `dispose` 收口。
## 执行租约
Session 在每个 Step 捕获不可变的 Power 执行视图。Power 被移除后,新 Step 立即不可见;已开始的
Step 可以继续使用捕获的实例,City 会等最后一个 execution lease 释放后再调用 `dispose`。
`city.close()` 会先停止 Session,再释放 Power 和 Workspace,并汇总释放错误。一个 Power 的
`dispose` 失败不会阻止其他 Power 清理。
### Power 配置
Source: https://downcity.ai/zh/agent-sdk-docs/powers/configuration/
一个 City 中,每个 Power ID 只有一份宿主配置:
```text
宿主保存 Power Config
→ City 持有一个 Power 实例
→ City 在每次执行时投影配置快照
→ Power 从当前 PowerContext.config 读取
```
```ts
const city = new City({
powers: [web_power],
workspaces: [workspace],
power_host: {
config: (power_id) => open_power_config_store(power_id),
notifications: (power_id, agent_id) => open_notifications(power_id, agent_id),
},
});
city.agents.add(agent);
```
`PowerConfigStore.get()` 同步返回当前完整配置快照,`set(config)` 原子替换完整配置。Agent
不保存 Power 引用或配置;同一份配置会提供给所有 Agent。多账号或多租户结构由具体 Power
在自己的 Config 中定义,City 不提供通用命名配置层。
Config action 由 Power 在 `initialize()` 中注册。City 调用 `city.powers.invoke_config()` 时注入
该 Power 唯一的 `get/set` store;Action、Hook、System 与 Availability 则从当前调用的
`PowerContext.config` 读取不可变快照。
配置保存后,Power 应主动释放或刷新受配置影响的长期资源。连接、回调和客户端对象不能写入
Config;它们仍由 Power 按账号、Agent 或其他真实领域身份管理,并最终由 `dispose()` 统一释放。
### Power Actions
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-actions/
Action 是 Power 的显式业务入口,由 `description`、可选输入 schema/command/api、超时和
`execute` 组成。
```ts
import { create_action } from "@downcity/city/power";
import { z } from "zod";
const status = create_action({
description: "Read the current execution scope.",
input_schema: { zod: z.object({}) },
timeout_ms: 30_000,
execute: async ({ context }) => ({
success: true,
data: {
agent_id: context.agent.id,
workspace_id: context.workspace.id,
session_id: context.session?.id ?? null,
turn_id: context.turn?.id ?? null,
tool_call_id: context.call.tool_call_id ?? null,
},
}),
});
```
每次调用只有一个只读输入 `context`:它包含受限 City、Agent、Workspace、Session、Turn、
Config 与 Storage 句柄,同时承载本次调用身份。`context.call.tool_call_id` 是模型协议分配的
工具调用标识(非工具入口为空),`context.abort_signal` 是本次调用的取消信号。
Action 应把 `context.abort_signal` 传给网络请求、轮询和长耗时任务。`timeout_ms` 只触发协作式
取消,底层操作必须响应信号。返回值必须是可 JSON 序列化的 `PowerActionResult`。
Action 可通过 `messages` 返回当前 Turn 的附加内容。`role: "agent"` 的 text/file/data Parts
会写入当前 Agent Message;`role: "user"` 的 text/context/file Parts 只作为下一模型 Step 的
临时输入,不会伪装成已持久化的 Session Message。Session 领域内统一使用 `agent`,只有模型协议
边界使用 `assistant`。
`command` 只把 Commander 输入映射为 payload;`api` 只把 HTTP 请求映射为 payload。它们不创建
第二套业务入口,最终都执行同一个 `execute`。
模型通过 Power 自身的工具调用 Action,工具名即 Power 名。Power 内部可以通过
`context.city.powers.run_action()` 调用 City 中的其他 Power;应用代码则从
`city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` 创建明确的执行视图,再调用 `run_action()`。
### Power 调用入口
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-call-surfaces/
Power 有四类调用面,分别服务不同角色:
| 入口 | 使用者 | 用途 |
| --- | --- | --- |
| `city.powers` | 宿主应用 | 添加或移除唯一实例、调用 host/config action |
| `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` | 宿主执行代码 | 在指定 Agent/Workspace 上调用 Action、Availability 或 Hook |
| `context.city.powers` | Power 代码 | 在当前作用域调用其他 Power Action、pipeline 或 effect |
| `skill` | Session 中的模型 | 调用 City 提供的 Power Action |
## City 管理面
```ts
city.powers.add(skill_power);
console.log(city.powers.snapshots());
await city.powers.remove("skill");
```
`city.powers.invoke()` 与 `invoke_config()` 调用 Power 在 `start()` 中注册的宿主管理 Action,不是 Session
执行 Action。
## Workspace 执行面
```ts
const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({
power: "skill",
action: "lookup",
payload: { name: "frontend-design" },
});
```
这个入口显式绑定 Agent 与 Workspace,因此 City 能创建正确的 `PowerContext`。
## PowerContext
```ts
const result = await context.city.powers.run_action({
power: "skill",
action: "list",
payload: {},
});
await context.city.powers.effect("audit.observe", result.data ?? null);
```
`context.session`、`context.agent.sessions` 和 `context.workspace` 是直接可调用句柄;ID 只用于稳定
身份和序列化。
## 模型 Tool
每个声明了 Action 的 Power 都会编译成一个以 Power 名为工具名的模型侧工具。
不存在共享的 `plugin_call` 入口:模型在这里调用的是 `skill` 工具,City 自有的图像与语音能力则通过 `ai` 工具调用。
```ts
skill({
action: "lookup",
args: { name: "frontend-design" },
});
```
Tool 永远绑定当前 Session 的 Agent/Workspace execution lease,不会读到其他 Agent 的绑定。
### Power Hooks 和 Resolve
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-hooks/
这一组能力最容易让人混淆的原因是:
- 它们都写在 Power 定义里
- 看起来都像“注册一个函数”
- 但运行语义完全不同
如果只记一句话,可以先记这个:
- `pipeline`:改值
- `guard`:拦截
- `effect`:做副作用
- `resolve`:给唯一答案
## 先看总对比
| 类型 | 谁来触发 | 是否返回新值 | 是否允许多个 handler | 失败会怎样 | 最适合的事 |
| --- | --- | --- | --- | --- | --- |
| `pipeline` | 宿主代码主动调用 | 会 | 会 | 抛错并中断当前调用 | 逐步加工一份值 |
| `guard` | 宿主代码主动调用 | 不会 | 会 | 任一 handler 抛错就中断 | 前置校验、权限判断 |
| `effect` | 宿主代码主动调用 | 不会 | 会 | 任一 handler 抛错就中断当前 effect 调用 | 记录、埋点、同步 |
| `resolve` | 宿主代码主动调用 | 会 | 不会,只能一个 | 没注册、重复注册、disabled 都会报错 | 提供唯一权威答案 |
## 最容易忽略的一点
Power 里声明了 hook,不代表它会自动执行。
真正的执行方式是:宿主代码在某个明确时机,主动调用某个点。
例如:
```ts
const value = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", value);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", value);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
也就是说:
- hook 是“扩展点定义”
- 宿主调用是“扩展点执行”
如果宿主从来不调用这个点,这个 hook 就永远不会跑。
Hook 声明在 Power 实例上,由 City 持有的 Registry 按当前 Agent/Workspace 作用域调度。Session 在每个执行检查点捕获一次 execution lease,因此本次调用看到的是稳定的 Power 集合;并发解绑会等待活跃 lease 释放。
Power 内部只能通过 `context.city.powers.pipeline()`、`effect()` 和 `run_action()` 协作,不能主动触发 `guard` 或 `resolve`。后两者代表宿主控制流和唯一权威决策,只从 `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` 等宿主执行面调用。
## 为什么 `resolve` 要单独看
很多人会下意识把 `resolve` 当成“另一种 hook”。
但它本质上更像:
- 一个 authority point
- 一个“最终到底谁说了算”的问题
所以它和前三种最大的区别是:
- 前三种允许多个 Power 一起参与
- `resolve` 只允许一个 Power 成为最终回答者
这就是为什么:
- 重复注册同一个 `resolve` 点会直接报错
- 没有 resolver 也会报错
- resolver 对应的 Power 如果 disabled,也会报错
## Session 完成后的 callback
`Agent` 自动在每个 Turn 收口时触发 `session.turn_committed`,成功、失败和停止均有事件。Session 本身继续保留,可接收下一轮输入。
通过 `AgentOptions.hooks` 注册自定义完成处理器:
```ts
import { Agent, SESSION_HOOK_POINTS, type SessionTurnCommittedHookValue } from "@downcity/agent";
import type { JsonValue, ToolCallContext } from "@downcity/type";
const agent = new Agent({
id: "reviewer",
model,
hooks: {
pipeline: {},
guard: {},
effect: {
[SESSION_HOOK_POINTS.turn_committed]: [Object.assign(async (value: JsonValue, context: ToolCallContext) => {
const event = value as unknown as SessionTurnCommittedHookValue;
await result_outbox.enqueue({
idempotency_key: event.completion_id,
session_id: context.session_id,
turn_id: event.turn_id,
status: event.status,
text: event.result.text,
});
}, { handler_id: "report_outbox" })],
},
},
});
```
示例中的 `result_outbox` 是应用自己提供的投递端口。事件还包含 `result.success`、可选 `result.error` 和本轮 canonical `messages`。根据 `context.session_origin` 和输入消息中的业务关联选择处理逻辑;每次请求的接收目标应单独保存。
Agent 在关闭本轮 canonical 消息之前,先将执行结果和接收方标识写入私有完成日志,再在后台调用处理器。日志写入失败会拒绝 `turn.finished`,保留可恢复状态,不会确认成功。写前记录已提交但进程尚未完成收口时,恢复会复用已保存结果,使用相同 `request_id` 不会重跑模型。`turn.finished` 成功兑现不表示 callback 或外部消息已送达。回调及委派结果回传失败分别按 1 秒到 60 秒的指数退避自动重试。
每个完成处理器必须有非空、唯一且跨重启稳定的 `handler_id`。事件保存提交时的接收方集合,每个处理器独立确认;暂时移除的处理器继续保持待投递,重排数组不会改变归属。已确认的处理器不因其它处理器失败而重放。Power 的完成 Hook 也需要声明 `handler_id`,SDK 自动加上 Power 名称作为命名空间。
投递语义是至少一次:外部副作用已完成、本地尚未确认时进程中断,仍可能重复调用。请用 `completion_id` 幂等提交业务副作用。callback 应完成入队后返回,并把 `context.abort_signal` 传给支持取消的 I/O。单次调用默认 30 秒超时;Agent 关闭会发出取消并停止等待,未确认记录保留到下次恢复。忽略取消的函数无法被 JavaScript 强制终止,原函数未结束前不会在同进程并发重试该接收方。函数本身不保存;重启后重新注册处理器。
持久化恢复需要为 Agent 绑定持久 Storage。自定义宿主应先装配全部 Agent、Workspace、Power 和订阅,再调用 `await agent.resume_work()`,恢复未投递完成事件与未完成委派。CLI 和 Desktop 已执行此恢复步骤。直接手动调用任意 Power effect 不会自动获得这套持久化语义。
Messenger 通过同一个完成 Hook 将 Channel 的最终输出写入可靠 Outbox,包含子 Session 回传触发的后续轮次。后台输出继承原调用轮次对应的 IM 消息和话题;同轮合并输入按 Conversation 去重。失败轮次发送简短失败通知,停止轮次不自动发送。单纯追加 Agent Message 不会触发 Turn 完成 callback。
## 推荐阅读顺序
1. [总心智模型](/zh/agent-sdk-docs/powers/hooks/overview)
2. [调用流](/zh/agent-sdk-docs/powers/hooks/call-flow)
3. [Pipeline](/zh/agent-sdk-docs/powers/hooks/pipeline)
4. [Guard](/zh/agent-sdk-docs/powers/hooks/guard)
5. [Effect](/zh/agent-sdk-docs/powers/hooks/effect)
6. [Resolve](/zh/agent-sdk-docs/powers/hooks/resolve)
7. [怎么选](/zh/agent-sdk-docs/powers/hooks/choose-the-right-one)
## 直接看完整场景
- [一条消息的完整流转](/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [角色解析为什么必须唯一](/zh/agent-sdk-docs/powers/hooks/examples/role-resolution-flow)
- [常见误用对比](/zh/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design)
## 相关文档
- [Power 总览](/zh/agent-sdk-docs/powers/overview)
- [Power Actions](/zh/agent-sdk-docs/powers/power-actions)
- [自定义 Power](/zh/agent-sdk-docs/powers/custom-power)
### Hooks 总览
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/overview/
这一组能力最好不要按“代码长什么样”来理解,而要按“运行时到底发生了什么”来理解。
## 共同点
这四种能力都有一个共同前提:
- Power 先声明 handler
- 宿主代码后调用某个点
例如:
```ts
const next = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", value);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", next);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", next);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
这段代码里,真正触发执行的是宿主,而不是 Power 自己。
## 最大区别
### `pipeline`
重点是“接过一个值,再交出一个新值”。
### `guard`
重点是“允许继续,或者直接抛错阻断”。
### `effect`
重点是“主值不变,只做副作用”。
### `resolve`
重点是“这个问题最终只能有一个回答者”。
## 把它们想成四种问题
### `pipeline` 在回答
“这份输入在进入下一步之前,要不要被加工一下?”
### `guard` 在回答
“这份输入现在有没有资格继续往后走?”
### `effect` 在回答
“这一步发生了之后,要不要顺手记录或同步一点东西?”
### `resolve` 在回答
“这个问题最终到底由谁给答案?”
## 为什么 `resolve` 最容易写错
因为很多人会把它写成“大家都来参与一点”。
但 `resolve` 的语义不是:
- 多个 Power 共同补一份答案
而是:
- 当前这个点,必须由一个 Power 作为唯一权威来返回结果
所以它更像“查最终角色”“查最终 provider”“查最终路由决策”,而不像“给消息继续补字段”。
## 下一步
- [调用流](/zh/agent-sdk-docs/powers/hooks/call-flow)
- [Pipeline](/zh/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/zh/agent-sdk-docs/powers/hooks/guard)
- [Effect](/zh/agent-sdk-docs/powers/hooks/effect)
- [Resolve](/zh/agent-sdk-docs/powers/hooks/resolve)
### 调用流
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/call-flow/
这篇最重要,就是把一个误区先拿掉:
- hook 不是“注册之后自动跑”
- hook 是“宿主代码在某个点主动调用”
## 两个角色
理解 hook / resolve,先分清两个角色:
### Power 作者
负责定义:
- 哪个点有 handler
- handler 收到什么值
- handler 返回什么结果,或者什么时候抛错
### 宿主代码作者
负责决定:
- 什么时候调用这个点
- 传什么值进去
- 接到返回值之后怎么继续
## 一个最小调用流
```ts
const value1 = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", input);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", value1);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", value1);
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve("auth.resolve_role", {
userId: "owner",
});
```
顺着这段代码看:
1. `pipeline` 先拿到 `input`,可能返回一个更完整的 `value1`
2. `guard` 决定 `value1` 有没有资格继续
3. `effect` 在不改 `value1` 的前提下记录副作用
4. `resolve` 单独回答另一个“唯一答案”问题
## 为什么前三种是“链路点”
因为前三种最常见的用法都是:
- 当前流程走到某一步
- 宿主把这一步的值交给 Power
- Power 在这个流程点上参与
这就像给执行链留了几个插槽。
## 为什么 `resolve` 更像“查权威答案”
因为它往往不是“链路继续往前加工同一个值”,而是:
- 宿主现在需要一个最终答案
- 它去某个命名点上拿答案
例如:
- 当前用户角色是什么
- 当前 provider 应该选哪个
- 当前某类请求最终交给谁处理
## disabled Power 会发生什么
这里也要分情况:
### `pipeline / guard / effect`
如果某个已注册 Power 当前是 disabled,这个 Power 的 handler 会被跳过。
也就是:
- 点还会继续执行
- 但 disabled 的 Power 不参与
### `resolve`
如果 resolve 对应的唯一 Power 被 disabled,就不是“跳过后继续”。
而是:
- 当前这个 resolve 点直接失败
因为它需要唯一权威答案,而不是“谁都不回答也可以”。
## 没有 handler 会发生什么
### `pipeline / guard / effect`
如果这个点一个 handler 都没有:
- `pipeline` 原样返回输入值
- `guard` 直接通过
- `effect` 直接结束
### `resolve`
如果没有注册 resolver:
- 直接报错
因为宿主明确在问一个必须有答案的问题。
## 读完这篇之后最该记住的
- hook 的本质是“宿主定义调用时机,Power 提供插入逻辑”
- `resolve` 的本质是“宿主要一个最终答案,Power 提供唯一回答者”
## 下一步
- [Pipeline](/zh/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/zh/agent-sdk-docs/powers/hooks/guard)
- [Effect](/zh/agent-sdk-docs/powers/hooks/effect)
- [Resolve](/zh/agent-sdk-docs/powers/hooks/resolve)
### Pipeline
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/pipeline/
`pipeline` 最核心的语义是:
- 输入一个值
- 返回一个新值
- 下一个 handler 接到的是上一个 handler 的输出
## 它不是广播,而是接力
这点特别重要。
如果有三个 Power 都挂在同一个 `pipeline` 点上,运行方式不是:
- 三个人都看到最初那份输入
而是:
- 第一个人改完,交给第二个
- 第二个改完,再交给第三个
所以 `pipeline` 最像一条装配线。
## 最小例子
```ts
const next = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", {
text: "Please summarize this page",
});
```
如果一个 Power 写成这样:
```ts
hooks: {
pipeline: {
"chat.enrich_message": [
async ({ value }) => {
const body = value as Record;
return {
...body,
source: "web",
};
},
],
},
}
```
那么宿主拿到的 `next` 就会带上新的 `source` 字段。
## 最适合的三类事
### 补字段
例如:
- source
- pageTitle
- locale
### 归一化
例如:
- 把布尔值统一
- 把空字符串变成 `null`
- 把别名字段收敛成稳定字段名
### 逐步增强
例如:
- 第一层加来源信息
- 第二层加会话信息
- 第三层加默认策略
## 最不适合的事
### 权限拦截
权限拦截更适合 `guard`,因为它的目的不是改值,而是决定能不能继续。
### 单纯记录日志
单纯记录更适合 `effect`,因为没有必要返回新值。
### 给最终唯一答案
那是 `resolve`,不是 `pipeline`。
## 写 pipeline 时最稳的实践
- 尽量返回完整新对象,不要隐式依赖外部共享状态
- 尽量让输入输出 shape 更稳定,而不是越来越混乱
- 只做“值加工”,不要顺手塞太多副作用
## 什么时候该拆出去
如果你的逻辑已经变成:
- 要做很多 I/O
- 要维护长期状态
- 要同时负责拦截和记录
那往往说明:
- 值加工仍然留在 `pipeline`
- 其他职责拆到 `guard`、`effect` 或 Power `lifecycle`
## 相关场景
- [一条消息的完整流转](/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [MessageAugmentPower 场景](/zh/powers-docs/examples/message-augment-power)
### Guard
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/guard/
`guard` 的核心语义很简单:
- 通过,什么都不返回
- 不通过,直接抛错
## 它的职责不是改值
很多人第一次写 guard,会忍不住在里面顺手改输入。
但 guard 最稳的职责其实只有一个:
- 判断当前流程能不能继续
所以如果你的主要目标是“改一下输入”,那就应该去写 `pipeline`。
## 一个最小例子
```ts
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", {
reviewed: true,
});
```
Power 里可以这样定义:
```ts
hooks: {
guard: {
"review.require_checked": [
async ({ value }) => {
const body = value as { reviewed?: unknown };
if (body.reviewed !== true) {
throw new Error("review is required before continuing");
}
},
],
},
}
```
## 多个 guard handler 怎么跑
如果同一个点上有多个 guard:
- 会按注册顺序依次执行
- 只要其中一个抛错,后面的就不再跑
所以它像一串安检门。
## 适合的典型场景
### 权限判断
例如:
- 当前用户有没有角色
- 当前请求有没有权限
### 前置条件判断
例如:
- 某个字段是不是必填
- 某个状态是否已经准备好
### 安全边界
例如:
- 某类请求是不是必须显式确认
- 某类输入是不是要先过审
## 不适合的场景
### 想继续执行但顺手记一条日志
记录日志应该拆到 `effect`。
### 想根据条件补默认值
补默认值应该拆到 `pipeline`。
### 想给最终决策答案
那是 `resolve`。
## disabled Power 时会怎样
如果某个 guard Power 当前 disabled:
- 它的 handler 会被跳过
- 其他 active guard 仍然会继续执行
所以 guard 的语义是:
- active 的 guard 参与检查
- disabled 的 guard 不参与
## 最稳的实践
- 让错误信息直接说明为什么被拦住
- 不要在 guard 里偷偷写很多副作用
- 不要把“改值”和“拦截”混成一件事
## 相关场景
- [一条消息的完整流转](/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [ReviewGuardPower 场景](/zh/powers-docs/examples/review-guard-power)
### Effect
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/effect/
`effect` 的核心语义是:
- 主值不变
- 只做副作用
## 什么叫副作用
这里的副作用通常是:
- 打日志
- 记录观测态
- 同步某个状态
- 上报分析事件
也就是说,它不是为了“产出一个新结果”,而是为了“顺手做点事情”。
## 一个最小例子
```ts
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", {
event: "incoming_message",
session_id: "sess_001",
});
```
Power 里可以这样定义:
```ts
hooks: {
effect: {
"audit.observe": [
async ({ value, power }) => {
console.log(`[${power}] observed`, value);
},
],
},
}
```
## 它和事件总线像,但不完全一样
很多人会把 `effect` 想成一个随便发事件的总线。
这个想法不算全错,但要注意两件事:
- 它依然是宿主主动调用的一个命名点
- 当前调用如果抛错,effect 调用本身也会失败
所以它不是那种天然“fire-and-forget、失败也无所谓”的消息队列。
## 多个 effect 怎么跑
如果一个点上有多个 effect handler:
- 会按注册顺序依次执行
- active Power 的 handler 都会跑
- 某个 handler 抛错时,当前 effect 调用会中断
## 最适合的场景
### 观测
例如:
- 记录谁来过
- 记录什么动作发生过
### 埋点
例如:
- 某类功能被触发了几次
- 某类错误出现在哪一步
### 同步
例如:
- 把一份状态同步到另一个存储
- 把一份事件写到审计日志
## 不适合的场景
### 想改值
改值应该用 `pipeline`。
### 想阻断流程
阻断应该用 `guard`。
### 想得到唯一答案
唯一答案应该用 `resolve`。
## 一个现实建议
如果你的 effect 很关键,而且失败后必须重试、必须持久化、必须有可靠交付保证,那它可能已经不是一个简单 effect 能承担的事了。
这时更合适的分层通常是:
- effect 只负责触发
- 真正可靠处理放进 Power `lifecycle` 或更完整的后台 runtime
## 相关场景
- [一条消息的完整流转](/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [AuditEffectPower 场景](/zh/powers-docs/examples/audit-effect-power)
### Resolve
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/resolve/
`resolve` 是最容易被误解的一种扩展点。
因为它写起来也像一个 handler,但它的语义不是“参与流程”,而是:
- 对某个问题给出最终答案
## 把它先和前三种分开
前三种更像:
- `pipeline`:大家一起加工
- `guard`:大家一起审查
- `effect`:大家一起做副作用
而 `resolve` 更像:
- 这个问题,最终谁说了算
## 为什么必须只有一个 resolver
因为它面对的问题通常不是“多个人都来补一点”。
而是:
- 最终角色是什么
- 最终 provider 是哪个
- 最终路由目标是谁
这些问题都要求:
- 一个确定答案
- 一个最终权威
所以当前实现里,同一个 `resolve` 点重复注册会直接报错。
## 一个最小例子
```ts
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>(
"auth.resolve_role",
{ userId: "owner" },
);
```
Power 可以这样定义:
```ts
resolves: {
"auth.resolve_role": async ({ value }) => {
const body = value as { userId?: unknown };
const userId = String(body.userId || "").trim();
if (!userId) {
throw new Error("userId is required");
}
if (userId === "owner") return "admin" as never;
return "member" as never;
},
}
```
## 失败规则要单独记住
### 重复注册
直接报错。
因为系统不允许一个点有两个最终裁决者。
### 没有注册 resolver
直接报错。
因为宿主明确在问一个必须有答案的问题。
### resolver 对应 Power 当前 disabled
也会报错。
因为这里不是“没人参与就算了”,而是“必须有一个 active 的最终回答者”。
## 什么时候特别适合用 resolve
### 最终角色解析
例如:
- 当前用户是 `admin`、`member` 还是 `guest`
### 最终策略选择
例如:
- 当前 provider 最后选哪个
- 当前模式最后落到哪个执行器
### 唯一所有权决策
例如:
- 当前对象由哪个 Power 负责解释
## 什么时候不要用 resolve
### 多个 Power 都想补一点值
这其实是 `pipeline`。
### 想看看能不能继续
这其实是 `guard`。
### 只是想记录发生了什么
这其实是 `effect`。
## 一个最稳的判断句
如果你的问题可以表述成:
- “最终答案到底是什么?”
那它很可能适合 `resolve`。
如果你的问题更像:
- “这个值要不要再加工一下?”
那它大概率就不是 `resolve`。
## 相关场景
- [角色解析为什么必须唯一](/zh/agent-sdk-docs/powers/hooks/examples/role-resolution-flow)
- [RoleResolverPower 场景](/zh/powers-docs/examples/role-resolver-power)
### 怎么选
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/choose-the-right-one/
这页只回答一个问题:
- 这段能力到底应该放在哪里
## 先看最短版本
如果你想要:
- 显式调用一个命名能力,用 `action`
- 逐步变换一个值,用 `pipeline`
- 在流程继续前阻断,用 `guard`
- 不改主值,只顺手记录或同步一些东西,用 `effect`
- 得到一个最终唯一答案,用 `resolve`
- 持有长期状态、worker 或连接,用 Power `lifecycle`
## 常见问题
### “我想给输入补默认字段”
用 `pipeline`。
### “我想在没有权限时立刻拦住请求”
用 `guard`。
### “我想每次都写一条审计日志”
用 `effect`。
### “我想知道用户最终角色”
用 `resolve`。
### “我想让外部调用者触发一个具名命令”
用 `action`。
### “我想要长期缓存、连接池或后台 worker”
用 Power `lifecycle`。
## 常见坏拆分
- 用 `resolve` 做多 Power 富化
- 把 `effect` 当隐藏可靠任务队列
- `guard` 一边阻断一边还改值
## 一个实用拆分模式
如果一个能力同时需要:
- 显式命令入口
- 运行链路扩展
- 长期状态
那更干净的拆法通常是:
- `action` 负责显式调用
- `pipeline / guard / effect / resolve` 负责链路扩展
- `lifecycle` 负责长期状态
## 相关场景
- [完整消息流](/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow)
- [常见 bad vs good 设计](/zh/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design)
- [Power Runtime Composition](/zh/powers-docs/examples/power-runtime-composition)
### 一条消息的完整流转
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/examples/end-to-end-message-flow/
这篇不再单看某一个点,而是把三种 hook 串起来。
## 场景
你有一条进入系统的消息,希望它经历这三步:
1. 先补充来源和页面信息
2. 再检查这条消息是否已经审核
3. 最后记录一次观测日志
## 宿主代码
```ts
const enriched = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", {
text: "Please summarize this page",
});
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", enriched);
await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", enriched);
```
## 对应的 Power 们
### MessageAugmentPower
负责 `pipeline`:
- 给输入补字段
### ReviewGuardPower
负责 `guard`:
- 判断能不能继续
### AuditEffectPower
负责 `effect`:
- 记录发生过什么
## 这三步为什么不能混成一个点
因为它们回答的是三种不同问题:
- `pipeline`:值要不要变
- `guard`:流程能不能过
- `effect`:要不要记录点什么
如果把它们都塞进一个点里,后面几乎一定会变得难读。
## 读这篇最该得到的感觉
hook 不是“某个 Power 自己决定什么时候跑”。
而是:
- 宿主先定义调用顺序
- Power 再在每个点里参与
## 相关文档
- [调用流](/zh/agent-sdk-docs/powers/hooks/call-flow)
- [Pipeline](/zh/agent-sdk-docs/powers/hooks/pipeline)
- [Guard](/zh/agent-sdk-docs/powers/hooks/guard)
- [Effect](/zh/agent-sdk-docs/powers/hooks/effect)
### 角色解析为什么必须唯一
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/examples/role-resolution-flow/
## 场景
宿主现在要回答一个问题:
- 当前这个用户最终是什么角色
这时它会调用:
```ts
const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>(
"auth.resolve_role",
{ userId: "owner" },
);
```
## 为什么这里不能有两个 resolver
因为如果两个 Power 都能回答:
- A 说是 `admin`
- B 说是 `member`
那宿主就必须再写一层仲裁逻辑。
但 `resolve` 这个设计本来就是为了避免“再加一层仲裁”。
它的意思就是:
- 当前这个点,必须提前决定唯一权威
## 所以当前实现才会这样设计
- 重复注册同一个 resolve 点:直接报错
- 没注册 resolver:直接报错
- resolver 对应 Power disabled:直接报错
这三个规则本质上是在保护一件事:
- 最终答案必须明确
## 如果你其实想让多人都参与
那你大概率不应该用 `resolve`。
更可能的正确结构是:
- 先用 `pipeline` 汇总上下文
- 最后再由一个 `resolve` 给最终答案
## 相关文档
- [Resolve](/zh/agent-sdk-docs/powers/hooks/resolve)
- [怎么选](/zh/agent-sdk-docs/powers/hooks/choose-the-right-one)
### 常见 Bad vs Good 设计
Source: https://downcity.ai/zh/agent-sdk-docs/powers/hooks/examples/bad-vs-good-design/
## 误用 1
需求:
- 给消息补默认字段
坏选择:
- 用 `guard`
更好选择:
- 用 `pipeline`
## 误用 2
需求:
- 权限缺失时立刻阻断请求
坏选择:
- 用 `effect`
更好选择:
- 用 `guard`
## 误用 3
需求:
- 每次执行后写一条审计日志
坏选择:
- 用 `pipeline`
更好选择:
- 用 `effect`
## 误用 4
需求:
- 判断用户最终角色
坏选择:
- 用 `pipeline`
更好选择:
- 用 `resolve`
## 误用 5
需求:
- 维护长期缓存和后台同步 worker
坏选择:
- 把所有逻辑都塞进 Power hooks
更好选择:
- 让 Power `lifecycle` 拥有长期状态
- 让 hooks 继续聚焦链路扩展
## 相关文档
- [怎么选](/zh/agent-sdk-docs/powers/hooks/choose-the-right-one)
- [Power Runtime Composition](/zh/powers-docs/examples/power-runtime-composition)
### Power System 文本
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-system/
Power 可以实现:
```ts
system(context) {
return "power-level system text";
}
```
## 它的作用
`power.system()` 表达的是:
- 这个 Power 希望向 agent 运行时补充一段 system 文本
典型用途:
- 注入 provider 使用规则
- 注入某类能力的操作约束
- 告诉 agent 当前有哪些外部能力已经准备好
在完整 runtime 的系统提示词收集链路里,Power system 是正式注入的一层。
当前 runtime 会:
- 遍历全部内建 Power
- 只对启用的 Power 取 `system()`
- 单个 Power 失败时 fail-open,不阻断主链路
## 什么时候适合写 Power.system
这些内容更适合放到 Power.system:
- 某个 Power 的使用方法论
- 当前 provider 的工作约束
- 已安装能力的统一说明
## 哪些内建 Power 典型用到了它
- `skill`
- `web`
- `sound`
它们都非常适合注入“能力说明型 system 文本”。
## 相关文档
- [当前有哪些内建 Power](/zh/agent-sdk-docs/powers/built-in-powers)
- [自定义 Power](/zh/agent-sdk-docs/powers/custom-power)
### Power Availability
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-availability/
Power 当前需要区分两个事实:
- 是否已经注册到 City,并因此投影到全部 Agent。
- 当前 Agent/Workspace 环境是否满足 Power 自己的 `availability()` 检查。
```ts
const powers = city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id });
powers.has("skill");
powers.status("skill");
await powers.availability("skill");
```
Power 加入 City 且 `start` 尚未完成时为 `initializing`;启动成功后为 `ready`;启动失败时为
`error`。普通 Action 的业务失败不会改变整个 Power 的状态。
`availability()` 只回答当前 Context 中是否真正可用,例如 Provider、依赖、模型或凭据是否就绪。
它不控制 Agent 是否绑定 Power;City 中的全部 Power 始终对全部 Agent 可见。
### Power HTTP 注入
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-http/
Power 可以声明:
```ts
http: {
runtime: {
auth_policies: [...],
register({ app, get_context, power_name }) {
// register routes
},
},
}
```
## 它解决什么问题
它让 Power 自己声明:
- 需要哪些 runtime HTTP 路由
- 这些路由需要什么鉴权策略
而 server 侧只负责统一装配。
## `auth_policies`
这里声明的是:
- 这组路由应该怎么鉴权
它的意义是:
- Power 只声明自己的安全需求
- runtime / console 负责消费这份声明
## `register`
这里是真正把路由挂到 runtime Hono app 上的地方。
Power 不需要自己启动 server,只需要把路由注册函数提供出来。
## 什么时候适合用 Power HTTP
这些场景通常适合:
- 你要暴露一个专门的 runtime HTTP 端点
- 这个端点更像 Power 功能面,而不是 session 面
- 你希望它和 Power 的 availability、鉴权、控制面视图保持一致
## 什么时候不适合
如果你只是:
- 做一般 session 对话
- 暴露 SDK session 路由
那应该仍然优先使用 agent 自带的 session HTTP 能力,而不是自己重复造一层 Power HTTP。
## 相关文档
- [当前有哪些内建 Power](/zh/agent-sdk-docs/powers/built-in-powers)
- [自定义 Power](/zh/agent-sdk-docs/powers/custom-power)
### 自定义 Power
Source: https://downcity.ai/zh/agent-sdk-docs/powers/custom-power/
Power 是 City 提供给 Agent 的能力。一个 City 中每个 Power ID 只有一个实例,所有已注册 Agent
自动获得全部 Power;Agent 不保存 Power,也不维护 Power 配置引用。
```ts
import { City, Workspace } from "@downcity/city";
import { Power, create_action } from "@downcity/city/power";
class NotesPower extends Power {
readonly name = "notes";
readonly title = "Notes Helper";
readonly description = "Adds note-related actions.";
readonly actions = {
status: create_action({
description: "Read current execution scope.",
execute: async ({ context }) => ({
success: true,
data: {
agent_id: context.agent.id,
workspace_id: context.workspace.id,
session_id: context.session?.id ?? null,
},
}),
}),
};
}
const workspace = new Workspace({ id: "project", path: process.cwd() });
const city = new City({ workspaces: [workspace], powers: [new NotesPower()] });
city.agents.add(agent);
```
Action、Hook、System、Resolve、Availability、HTTP 与 Lifecycle 都通过 `PowerContext` 工作。
`context.agent`、`context.workspace`、`context.session` 和 `context.turn` 是受限的直接句柄;
运行数据写入 `context.storage`,不需要只依赖 ID 回查对象。
完整的 module、manifest、Context 与测试说明见
[Power 开发指南](/zh/agent-sdk-docs/powers/power-development)。
### Agent Powers Docs Moved
Source: https://downcity.ai/zh/agent-sdk-docs/powers/built-in-powers/
具体 built-in Power 的说明现在已经迁移到 [Agent Powers Docs](/zh/powers-docs)。
推荐从这里开始:
- [Agent Powers Docs 首页](/zh/powers-docs)
- [Built-ins Overview](/zh/powers-docs/builtins/overview)
- [Examples](/zh/powers-docs/examples)
如果你想先看 Power 的概念层文档,请继续阅读 [Power Overview](/zh/agent-sdk-docs/powers/overview)。
### Power 开发指南
Source: https://downcity.ai/zh/agent-sdk-docs/powers/power-development/
Power 作者 API 位于 `@downcity/city/power`。Power 是一个长期实例,不使用按 Agent 创建实例的
工厂函数;需要区分执行范围时读取 `PowerContext`。
```ts
import { Power, create_action } from "@downcity/city/power";
export default class ExamplePower extends Power {
readonly name = "example";
readonly title = "Example";
readonly description = "An example Power.";
readonly actions = {
status: create_action({
description: "Return the current scope.",
execute: async ({ context }) => ({
success: true,
data: {
agent_id: context.agent.id,
workspace_id: context.workspace.id,
session_id: context.session?.id ?? null,
tool_call_id: context.call.tool_call_id ?? null,
},
}),
}),
};
}
```
Workspace 文件使用 `context.workspace.files`;Power 私有状态使用 `context.storage.files`;
`context.session` 可以直接 `prompt()`、`stop()`、`context()` 或订阅变化。需要调用其他 Power
时使用 `context.city.powers`。
宿主可以直接把实例交给 City:
```ts
const city = new City({ powers: [new ExamplePower()], workspaces: [workspace] });
city.agents.add(agent);
```
可安装 Power 的 `main` 默认导出实例或 `{ power, main }`,不再提供 `module.create()`。Renderer
只通过宿主 action gateway 通信,不能访问 Node、Electron 或 Agent 全量对象。
## 实例化时注入业务回调
Power 在业务提交点报告自己的变化,应用在构造时注入处理函数。Memory 的工具、界面、Provider 直接操作和后台 Profile 优化共用同一批提交点:
```ts
const memory_power = new MemoryPower({
platform_root_path,
on_changed: (event) => {
if (event.kind === "knowledge") {
application.reload_memory(event.memory_id);
} else {
application.reload_profile(event.target, event.agent_id);
}
},
});
```
MemoryChange 包含 `operation: written/deleted`。knowledge 携带稳定 memory_id;profile 携带 target 和 agent_id(User 为 null)。事件不携带正文、物理路径或 Action 路由信息。每条实际提交的证据或投影分别通知,因此多步操作可能通知多次,后续失败也不撤销先前已写入的事实。删除不存在的记录、重复 Profile 追加及无正文变化的编辑不通知。
回调在持久化成功后调用,同步异常和 Promise rejection 独立记录,不改变数据结果,也不等待回调 Promise。应用可合并刷新,自己决定 IPC 和界面更新。Task 的 on_changed 表达定义提交与执行状态变化;Session 的 on_created/on_removed 保持自己的生命周期协议。
没有通用 action_hooks、PowerOptions 或 SDK invalidate。现有 pipeline/guard/effect 仍用于会话检查点,notifications.publish 用于用户提醒。业务回调随实例持有,没有历史重放或可靠重试,外部进程直接改文件不自动触发。其他 Power 应逐个在自己的业务提交点设计,不能从 Memory 的支持推断所有 Power 都已有实时回调。
测试时重点验证:City 内实例唯一、`initialize/dispose` 各一次、每次调用获得正确且独立的
`PowerContext`,以及 Hook 作用域在 Step 结束后释放。
### RemoteAgent 快速开始
Source: https://downcity.ai/zh/agent-sdk-docs/remote-agent/quickstart/
`RemoteAgent` 是远程 session 客户端。
当前它支持连接:
- `http://...`
- `https://...`
- `rpc://...`
## 服务端先准备好可访问入口
### 方式 1:通过 Downcity 发布 HTTP
```bash
downcity on
```
### 方式 2:服务端启动 City RPC 或 HTTP
```ts
import { Agent } from "@downcity/agent";
import { City } from "@downcity/city";
import { Workspace } from "@downcity/city";
const workspace = new Workspace({ id: "project", path });
const agent = new Agent({ id, model });
const city = new City({ workspaces: [workspace] });
city.agents.add(agent);
await city.rpc({ host: "127.0.0.1", port: 15314 });
```
## 客户端连接
### 连接 HTTP
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "http://127.0.0.1:5314/agents/assistant/workspaces/project",
});
const session = await agent.sessions.create();
const turn = await session.prompt({
query: "总结一下当前仓库结构",
});
const result = await turn.finished;
```
### 连接 RPC
```ts
import { RemoteAgent } from "@downcity/city";
const agent = new RemoteAgent({
url: "rpc://127.0.0.1:15314/assistant/project",
});
```
## 一个重要限制
远程 Session 不提供模型配置方法:
```ts
// 远程 Session 不提供模型配置方法
```
模型 ID、默认值和运行时实例始终由服务端 Agent 管理。
如果你是纯 SDK 模式,应该在服务端本地 `Agent` 或本地 `session` 上设置模型。
如果你是 Downcity 集成模式,模型通常来自:
- Agent config `execution.modelId`
- 已连接的 `Federation AIService`
## 适用场景
- 另一个进程已经持有本地 Agent
- 你只想通过 HTTP 或 RPC 方式消费 agent 能力
- 你想把调用方和执行方分开
如果你有前后端、本地守护进程加调用端、或者多个消费者共享同一个 agent host,这种模式会很自然。
### Remote Session API
Source: https://downcity.ai/zh/agent-sdk-docs/remote-agent/remote-session-apis/
通过 `RemoteAgent` 拿到 session 后,最常用的方法有:
- `get_info()`
- `prompt()`
- `subscribe()`
- `messages()`
- `system()`
- `interactions()` / `respond()`
- `status()` / `set({ security })`
- `stop()`
- `fork()`
这些 API 尽量和本地 session 的使用面保持一致,方便你在本地和远程模式之间复用同一套心智模型。
## 获取 session
```ts
const session = await agent.sessions.create();
```
如果你要继续一个已有远程会话:
```ts
const session = await agent.sessions.get("repo-analysis");
```
这样就可以切换到一个已知远程 session,而不是每次都从头开始。
## 发起一轮 prompt
```ts
const turn = await session.prompt({ query: "继续" });
await turn.finished;
```
## 浏览器与 Chrome 扩展
浏览器代码使用专用入口,支持 HTTP/HTTPS,不会打包 Node 网络和文件系统模块:
```ts
import { RemoteCity, type PromptPower } from "@downcity/city/browser";
const city = new RemoteCity({ url: "http://127.0.0.1:5314" });
const agents = await city.agents.list();
const agent = await city.agents.get(agents[0].id);
const session = await agent.sessions.create({ origin: { type: "chrome" } });
```
列表只返回当前凭据可访问的 Agent,`agents.get()` 会再次确认身份和权限。当前 RemoteCity 支持 HTTP/HTTPS;Node 根入口也导出相同 API。调用方无需拼接 Agent 路径。使用完调用 `await city.close()`,释放该连接拥有的事件流和执行通道。浏览器调用方保持页面或扩展侧栏打开,直到当前 Turn 完成,以执行其提供的 Powers。
## 在本轮提供 Power
可以在 `prompt()` 中提供调用方的能力。SDK 自动管理执行通道,无需注册 binding 或手动释放。
```ts
import type { PromptPower } from "@downcity/agent";
const browser_power: PromptPower = {
name: "browser",
description: "读取调用方的当前页面",
instruction: "分析页面前先调用 read_page。",
actions: {
read_page: {
description: "读取当前页面标题",
access: "read",
input_schema: {
type: "object",
properties: {},
additionalProperties: false,
},
execute: async (_input, context) => {
context.abort_signal.throwIfAborted();
return { title: document.title };
},
},
},
};
const turn = await session.prompt({
query: "分析当前页面",
powers: [browser_power],
});
const result = await turn.finished;
```
这个示例在浏览器调用方运行;真实函数留在调用方,Agent 服务端只接收能力描述。HTTP、HTTPS、RPC 及本地 Session 使用相同的 Prompt 形态。
- Power 仅对当前 Turn 有效,后续 Prompt、fork 和委派不会自动继承。
- 与已有工具重名会拒绝输入;非空 `powers` 只能在启动 Turn 时装配,同对象追加输入不能替换本轮能力。每次 Prompt 必须携带可信宿主绑定的 `actor`,不同对象在本轮结束前会收到 busy。
- Action 直接返回 JSON 值,不能返回函数、文件句柄或客户端路径附件;默认超时 60 秒,可设置 `timeout_ms`,上限 5 分钟。
- `context.call_id` 可用于业务幂等,`context.abort_signal` 用于协作取消。取消不代表已经发生的外部副作用被撤销。
- 执行通道断开会中断本轮,不会自动重跑客户端写操作;重新提交工作前需要确认外部状态。
- `request_id` 重复提交不能更换 query 或能力描述;已中断请求不能用于接管原执行。
已有 City Power 可通过 `@downcity/city/power` 的 `export_prompt_power(power, host, resolve_site, instruction?)` 显式导出。提供方必须使用自己的 Power runtime,并把远端调用身份映射到本地授权的主体和资源。导出不会重新初始化 Power,也不会把 City hooks、HTTP 路由或 UI 注册到远端;返回消息等非 JSON 输出不支持跨端传递。
## 订阅后续事件
```ts
const unsubscribe = session.subscribe((event) => {
console.log(event);
});
unsubscribe();
```
订阅会收到 `message | part | delta | turn | file_diff | session | warning` 七类 Mutation。用户参与请求表现为所属 Tool Part 上的 pending Interaction,该 Tool 同时进入 `waiting-user`;模型调用失败会产生 `warning / model_request`。通过 `session.respond(...)` 提交结构化响应。
## 历史消息
```ts
const messages = await session.messages();
```
## System prompts
```ts
const system = await session.system();
```
## 状态与安全策略
```ts
const status = await session.status();
await session.set({
security: { approval_mode: "always-allow" },
});
```
远程 Session 只能写入可序列化的安全配置,不能传输本地模型实例。
## 分叉
```ts
const next = await session.fork();
```
### 传输行为
Source: https://downcity.ai/zh/agent-sdk-docs/remote-agent/transport-behavior/
RemoteAgent 支持两种传输形态:
- `http://...` / `https://...`:调用 Downcity City HTTP gateway 挂载出来的 SDK 路由
- `rpc://...`:直连 City RPC server
## 普通请求
例如:
- 创建 session
- 列出 session
- `prompt`
- `history`
- `fork`
这些都走普通 JSON 请求响应。
## 事件流请求
走 HTTP 时,`events` 是 NDJSON 风格的长连接返回。
走 RPC 时,同样的 session events 会通过 RPC 连接投递。
对 HTTP 调用方来说,客户端会不断收到一行一行的 JSON 事件。
每一行都可以看成一条独立的 session event。
## 对调用方意味着什么
- 如果你只关心某一轮最终结果,就等 `turn.finished`
- 如果你要做实时 UI,就用 `subscribe()`
如果你要展示工具调用过程、渐进输出或实时聊天 UI,`subscribe()` 通常就是最合适的入口。
### 当前限制
Source: https://downcity.ai/zh/agent-sdk-docs/remote-agent/limitations/
RemoteAgent 当前最重要的限制是:
## 不提供远程模型配置
远程 Session 不提供以下两种接口:
```ts
// 远程 Session 不提供模型配置方法
```
原因很简单:
- 模型实例通常不可序列化
- 它应该由服务端本地 session 持有
## 推荐做法
- 在服务端构造本地 Agent 时传入模型实例
- 在 Downcity 集成模式里,由 CLI 读取 `execution.modelId` 并在构造 Agent 时传入模型实例
- 客户端只做远程调用
如果你把模型控制逻辑也放到客户端,就很容易和当前能力边界冲突。
## 另一个现实限制
如果服务端既没有:
- 通过 Downcity Agent 网关暴露 HTTP
也没有:
- 通过服务端 `City` 的 `city.rpc({ ... })` 启动 RPC
那么 `RemoteAgent` 无法连接。
从体验上看,RemoteAgent 更像一个瘦客户端,而不是会自动替你补全服务端能力的智能代理。
### 例子:客户端 / 服务端模式
Source: https://downcity.ai/zh/agent-sdk-docs/remote-agent/example-client-server/
这种模式通常是:
- 服务端进程持有本地 `Agent`
- 服务端通过 Downcity 或 `City` 的 `city.http()` / `city.rpc()` 暴露 HTTP / RPC
- 客户端进程创建 `RemoteAgent`
这种结构的最大优点是职责清晰:
- 服务端维护执行上下文
- 客户端只负责发请求和消费结果
它适合:
- 前后端分离
- 本地守护进程 + 调用端
- 多个调用方共享同一个 agent 服务
### Session 总览
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/overview/
`Agent` 拥有身份、模型、自定义工具与 `AgentSessions`;Power 由 City 提供。每个 Session
可以接收一个 Workspace,以获得项目 Tool 和环境。真正承载一次连续对话、历史、流式输出与
工具审批的是 `Session`。
一个 Session 是一条持久化的、线性有序的 Message 序列。你可以离开进程后重新 `get()` 它,也可以在本地 `Agent` 与 `RemoteAgent` 之间复用几乎相同的调用方式。
## 先记住这条主线
```text
create / get
-> 读取 messages() 初始快照
-> subscribe() 实时变化
-> prompt() 返回 turn handle
-> 处理 delta、part、approval
-> await turn.finished
-> 需要时 stop()、fork() 或 archive()
```
订阅要在 `prompt()` 前建立。它只发送建立订阅之后的 Mutation,不会补发已经发生的流式输出。
## 一个完整的本地例子
```ts
const session = await agent.sessions.create({
workspace,
});
await session.set({ model });
const initial_page = await session.messages();
render_messages(initial_page.items);
const unsubscribe = session.subscribe((mutation) => {
apply_mutation_to_ui(mutation);
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (interaction) render_interaction(interaction);
});
const turn = await session.prompt({ query: "分析这个仓库的 Session 设计" });
const result = await turn.finished;
set_turn_status(turn.id, result.success ? "completed" : "failed");
unsubscribe();
```
`apply_mutation_to_ui()` 应按 `message_id + revision` 合并完整快照;对 `delta` 只向对应文本或 Tool 输入追加增量。完整做法见 [实时订阅](/zh/agent-sdk-docs/sessions/subscribe) 和 [重连与同步](/zh/agent-sdk-docs/sessions/reconnect-and-sync)。
失败时,用户可见错误由已收口 canonical Agent Message 内的 Error Part 提供。`turn.finished.error` 用于控制流、日志或非 Message 界面;已经渲染 Session Message 的聊天界面不要再把它追加成第二条错误。
## Session 里有哪些对象
| 对象 | 作用 | 什么时候使用 |
| --- | --- | --- |
| `SessionMessage` | 持久化历史的唯一模型 | 首次加载、重连、历史浏览 |
| `SessionMutation` | 订阅后的实时变化协议 | 流式渲染、工具状态、标题更新 |
| `TurnHandle` | 一次输入归属的执行句柄 | 等待最终结果、停止后检查结果 |
| `SessionInteractionRequest` | 当前 Session 等待用户响应的请求 | 审批、问答、后台处理 |
Message 和 Mutation 不要混用:Message 是可恢复的状态快照;Mutation 是把状态变化及时送到客户端的协议。
## Session 运行模型
Session 是输入排序和持久化的一致性边界。Prompt、模型修改、环境更新、Power 更新、
审批模式修改和 Compact 都在服务端进入同一个 FIFO,在 Turn 开始前或 Model Step
检查点按提交顺序执行。远程客户端只调用 Session API,不在客户端复制调度逻辑。
```mermaid
flowchart TB
App["SDK / Remote Client"] --> Session["Session
API 与生命周期边界"]
subgraph Runtime["权威 Session Runtime"]
Session --> Queue["有序输入队列
SessionCommand FIFO"]
Queue --> Loop["SessionLoop
唯一消费者与 Turn 调度器"]
Loop --> Executor["Executor
Model / Tool Step Loop"]
Session --> Security["Security Policy
configured / effective"]
Security --> Executor
Session --> Status["status() 只读投影"]
Loop -.-> Status
Security -.-> Status
Session --> Interactions["Interactions
Approval / Question waiter"]
Session --> Messages["SessionMessages
Canonical 状态"]
Executor --> Messages
Executor --> Interactions
Loop -->|"Command 完成信息"| Messages
Messages --> Store["Message Store"]
Messages --> Events["Session Mutation"]
end
Session -->|"respond() 直接兑现 waiter"| Interactions
Session -->|"stop() 立即中断"| Turn
Events --> App
```
只有需要 FIFO 顺序和 Step 检查点的操作才进入队列。`respond()` 必须立即恢复当前
Interaction,`stop()` 必须立即中断当前 Turn,因此两者不会排队。
Command 可以声明成功后的 Action 完成信息。Session 先执行领域修改,再持久化
包含该 Action Part 的 canonical Agent Message;Message 提交成功后才发布对应 Mutation。完成信息落盘失败
不会回滚已经生效的配置,也不会发布并不存在的成功事件。
## 本地与远程的差异
两者都有 `prompt()`、`stop()`、`subscribe()`、`messages()`、`interactions()`、`respond()`、`status()`、`set({ security })` 和 `fork()`。本地 Session 还提供 `set({ model })`、`rename()`、`syncshot()` 与 `snapshot()`;远程 Session 的模型和 system 策略由服务端宿主管理。
使用 `await session.rename(title)` 持久化本地 Session 的用户可见标题。该方法返回规范化后的标题,并在 metadata 提交后发布 canonical Session title Mutation。客户端应从 Mutation 更新导航投影,不要保存第二份标题。
| 场景 | 配置模型 |
| --- | --- |
| 本地 `AgentSession` | `session.set({ model })`,接收 `ModelClient` 实例 |
| `RemoteAgentSession` | `session.set({ security })`;不接受模型实例 |
| Downcity Agent 项目 | CLI 解析 `execution.modelId`,在构造 Agent 时传入 `ModelClient` 实例 |
远程客户端不参与模型选择;模型 ID、默认值和恢复策略都属于服务端宿主。
## API 地图
- 管理本地会话:`agent.sessions.create/get/list/archive/archived/clean_archive/remove/clear_messages()`
- 输入与完成:`session.prompt()`、`turn.finished`
- 读取与实时:`session.messages()`、`session.get_info()`、`session.subscribe()`
- 重命名本地 Session:`session.rename(title)`
- 运行控制:`session.stop()`、`session.set()`、`session.fork()`
- system 刷新与持久化:[`session.syncshot()`](/zh/agent-sdk-docs/sessions/syncshot)、[`session.snapshot()`](/zh/agent-sdk-docs/sessions/snapshot)
- 工具审批:`session.interactions()`、`session.respond()`、`session.set({ security })`、`session.status()`
- 调试上下文:`session.system()`、[元数据与落盘](/zh/agent-sdk-docs/sessions/metadata-and-storage)
## 下一步
先从 [管理 Session](/zh/agent-sdk-docs/sessions/session-management) 建立会话,再阅读 [输入与 Turn](/zh/agent-sdk-docs/sessions/prompt-and-turns) 和 [Message](/zh/agent-sdk-docs/sessions/messages)。
### Session 来源与类型
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/origin-and-types/
每个 Session 都有一个来源(`origin`),由两个字段组成:`category` 和 `type`。它们回答两个不同的问题,你需要分别决定。
`category` 是分类,只有 `chat` 和 `work` 两个值。它决定会话存在哪个物理分区,也决定这个会话能不能派发工作。`type` 是角色,说明这条会话具体是什么。分类回答「它在哪一层」,角色回答「它是什么」。
## 该用哪个分类
问自己一件事:这条会话是用来对话,还是用来干活。
| 你要做的事 | 分类 | 角色 |
| --- | --- | --- |
| 和用户持续对话(你的产品主界面) | `chat` | `agent` |
| 接入外部聊天渠道(Telegram、飞书等) | `chat` | `channel` |
| 群聊里的成员会话 | `chat` | `group` |
| 浏览器扩展里的问答 | `chat` | `chrome` |
| 执行一件明确的工作 | `work` | `work` |
| 由任务调度触发的一次执行 | `work` | `task` |
不传 `origin` 时默认是 `{ category: "work", type: "work" }`,也就是一条普通工作会话。
## 分类决定的两件事
**物理分区。** 会话落在 `sessions///`。分类只有两个,所以一个 Agent 的会话目录最多两层。同一个 `session_id` 可以分别存在于 `chat` 和 `work` 下——它们互不冲突,因为身份由分类和 ID 共同组成。
**能不能派发工作。** `chat` 类的会话可以开启、检查、答复、停止其他会话,也可以归档。`work` 类的会话专注做被派发的那件事,不再递归派发。归档只有 `work` 类能做。
角色不参与分区的形成,它只影响具体的行为分支,例如外部聊天要选平台 prompt、群聊成员要校验归属。
## 开一条对话会话
```ts
const session = await agent.sessions.create({
origin: { category: "chat", type: "agent" },
workspace,
});
```
## 派一件事,让它干活
```ts
const work = await agent.sessions.create({
origin: { category: "work", type: "work" },
workspace,
});
const turn = await work.prompt({ query: "把这份报告整理成表格" });
await turn.finished;
```
## 按分类与角色查会话
`list` 的第一个维度是分类;分类不足以区分角色时再加角色。
```ts
// 某个分类下的全部会话
const conversations = await agent.sessions.list({ category: "chat" });
// 只看 Agent 主会话
const main = await agent.sessions.list({
category: "chat",
origin_type: "agent",
});
```
恢复一个已有会话时传的也是分类:
```ts
const session = await agent.sessions.get(session_id, "chat", { workspace });
```
## 自定义角色
已知的六个角色覆盖了常见场景。如果你的产品有自己的会话语义,可以声明自定义角色,但必须同时给出分类,否则创建会直接失败:
```ts
const session = await agent.sessions.create({
origin: {
category: "work",
type: "automation/daily",
schedule_id: "daily-report",
},
});
```
分类必须在 `chat` 和 `work` 之间选一个,因为它是唯一决定分区与派发能力的字段。角色名可以是任意非空字符串,两者之外的字段会随来源一起持久化,由你自己解释。
这个约束是有意的:新增一个角色是零成本的,新增一个分区需要显式表达,不会因为写错一个字符串就静默落到别的目录里。
### Agent 主会话
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/agent-chat/
Agent 主会话的 `origin.type` 是 `agent`,`session_id` 等于 Agent 的 `id`。它保存用户与 Agent 的持续对话,并可以派发、查看、回复和停止工作会话。
## Desktop 中的专属目录
Desktop 为每个 Agent 提供固定目录:
```text
~/.downcity/agents//main/
```
这是主会话自己的 Workspace,为文件、Shell 和 Power 提供执行环境。它不出现在 Works 项目列表里;切换项目或重新打开 Agent 都不会改变主会话目录。会话消息仍保存在 `sessions/agent//`,与 `main` 中的文件分开。
项目任务由 `work` 会话执行。派发时必须明确指定项目 Workspace,不能继承主会话的 `main` 目录。
## SDK 取用与恢复
SDK 不决定目录在磁盘上的位置,目录由宿主提供。首次取用可以传入专属 Workspace:
```ts
const agent = new Agent({
id: "coder",
model,
workspace: main_workspace,
resolve_work_workspace: async (workspace_id) => {
const workspace = project_workspaces.get(workspace_id);
if (!workspace) throw new Error("Project Workspace not found");
return workspace;
},
});
await agent.sessions.self({ workspace: main_workspace });
// self 的 Workspace 只用于创建;已有会话通过 get 明确恢复运行时实例。
const session = await agent.sessions.get(agent.id, "agent", {
workspace: main_workspace,
});
const turn = await session.prompt({ query: "请检查项目 CI,并派发修复任务" });
await turn.finished;
```
`project_workspaces` 和 `main_workspace` 由宿主创建和持有。`resolve_work_workspace` 仅解析项目工作目标,不负责恢复主会话,也不自动选择项目。宿主应拒绝未登记的项目与主会话私有目录。
Workspace 实例不落盘,Session 只保存 `workspace_id`。每次重启后由宿主重新注入实例;仅有持久化 ID 并不意味着文件与 Shell 已经可用。`get()` 在缓存实例存在但目录实例尚未恢复时也会应用注入。
## 派发工作
所有 Session 类型都可使用 `session` Power,通过 `{ action, args }` 调用。Chat(包括 Agent 主会话与 Messenger 的 `channel` 会话)可以创建多个一级 Work;一级 Work 可以创建多个二级 Work,支持并行,不设固定数量或并发上限;二级 Work 和 Task 只能使用 `check/messages`。省略 `action`(调用 `session({})`)返回动作说明和参数 Schema:
| action | 用途 |
| --- | --- |
| `start` | 在明确指定的项目中创建直接子 Work,受理后返回 |
| `send` | 保留上下文,继续自己的直接子普通 Work |
| `check` | 列出当前 Agent 的活动与归档会话,或读取指定会话的状态与待处理交互 |
| `messages` | 分页读取当前 Agent 的 Chat、Work、Task 历史,包括归档及压缩前消息 |
| `reply` | 向直接子普通 Work 转交用户对 question 的真实回答,不能代替用户审批 |
| `stop` | 停止自己的直接子普通 Work 及其子工作 |
| `archive` | 归档自己的直接子普通 Work,目标及子树须无未结束工作或待处理交互 |
所有类型的读取权限相同;创建和控制权限按上述角色限制。控制目标不能是自己、祖先、兄弟、其他 Chat 的子会话或 Task。完整权限矩阵与生命周期见[管理 Session](/zh/agent-sdk-docs/sessions/session-management)。
模型先通过 `context.workspaces.list` 查看可用目录,再提供项目 ID:
```text
session({
action: "start",
args: {
workspace_id: "project-workspace-id",
query: "修复 CI 中失败的三个测试,说明原因与验证结果。",
title: "修复 CI"
}
})
```
工作会话收到完整任务文本与目标项目目录,不自动继承主会话历史或私有目录,但可以通过 `check/messages` 主动读取当前 Agent 的相关会话。结果按“二级 Work → 一级 Work → Chat”逐级自动回传;一级 Work 汇总子结果后再回报,用户可继续交流,无需轮询。
`check` 指定目标时,必须同时提供 `session_category: "chat" | "work"` 与 `session_id`;两者均省略则列出当前 Agent 的活动与归档会话。`messages` 始终要求这两个参数,Task 的分类为 `work`。读取不会改变回传路径或恢复执行,历史与子结果均为参考数据,不代表新的用户指令或授权。
## 恢复委派
SDK 宿主在绑定持久化存储并完成 Workspace、Power 和消息订阅装配后调用:
```ts
await agent.resume_work();
```
该方法启动未完成委派的后台恢复,也可重试此前失败的投递;它不等待工作完成。Desktop 在启动时自动调用。独立 SDK 默认使用进程内存储,跨进程恢复需要 City 提供持久化存储。
恢复沿用 Session 的既有语义:已完成的 Turn 复用持久化结果;尚未执行的输入可继续提交;已有流式输出的中断 Turn 收口为中断失败并回传,不自动重做已经发生的工具操作。
主目录通过 Agent 的 `workspace` 提供,项目目录通过 `resolve_work_workspace` 恢复。宿主如需恢复 Session 专属模型或消息订阅,可提供幂等的 `prepare_work_session(session)`;它在子执行及父会话接收结果之前调用。该回调仅装配资源,不提交模型输入。
`session({ action: "stop", args: { session_id } })` 只能停止自己的直接子普通 Work,并同时停止其子工作。已保存结果保留,已收到的结果不能撤回。归档历史仍可读取;一级 Work 可以为独立工作继续创建新子会话。
## 会话身份
| 来源 | 标识 | 目录 |
| --- | --- | --- |
| `agent` | Agent ID | Desktop 固定的 `main` 目录 |
| `work` | SDK 生成 | 显式指定的项目 Workspace |
| `task` | SDK 生成 | 任务定义指定的执行目录 |
| `group` | SDK 生成 | 群聊执行上下文指定的目录 |
`agent.sessions.list()` 默认只列出 `work` 分区,因此主会话不会混入项目会话列表。归档或删除主会话后,再次取用会重建会话,专属 `main` 目录中的文件仍独立于会话历史。
### 管理 Session
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/session-management/
`agent.sessions` 是本地 Session 集合入口。Session 在创建时可以选择一个 Workspace 作为初始目录,之后可以在会话中切换。
## Turn 占用对象与请求 callback
`session.prompt()` 的 `actor` 可省略,省略时按本地用户 `{ kind: "user", user_id: "local" }` 处理,适用于宿主直接转发的用户输入。它决定 Turn 占用与 `request_id` 归属,因此自动执行入口必须显式声明真实身份。空闲时首条输入取得当前 Turn 的占用;同对象的后续输入进入该 Turn 内部队列,在模型输入消费节点处理。不同对象收到 `SessionBusyError`,错误码为 `session_busy`,输入不入队。`mode: "exclusive" | "send"` 已移除。
跨 Session 输入使用 `create_session_prompt_actor({ agent_id, workspace_id, session_id, origin_type })`;来源 Turn 不参与对象比较。Task、Group 等可信宿主执行使用 `create_runtime_prompt_actor({ namespace, object_id })`,不能冒用用户。自定义用户身份使用 `create_user_prompt_actor(user_id)`。SDK/远程 SDK 的调用者负责提供真实身份,不能把该参数开放给模型任意填写。
`session.status()` 返回 `occupied_by`。其他 Session 占用期间,用户普通输入也会被拒绝;需要接管时,先 `await session.stop(expected_turn_id)`,确认旧 Turn 已停止,再提交用户输入。如果 Turn 已变化,停止会拒绝。停止和重新提交之间仍可能有其他对象先取得占用,此时返回 busy。
相同对象重试同一 `request_id` 复用原结果,其他对象不能复用该键。占用属于 City 持有的唯一 Session 实例,不是多个 City 共享存储时的分布式锁。
应用注册回调,Tool 在提交时选择名称和业务参数:
```ts
const agent = new Agent({
id: "reviewer",
model,
callbacks: {
review_completed: async ({ result, data, delivery_id }, context) => {
await review_outbox.enqueue({
idempotency_key: delivery_id,
result,
data,
});
},
},
});
const work_session = await agent.sessions.create({ workspace });
const turn = await work_session.prompt({
request_id: "review-123",
query: "审查支付代码",
callback: { name: "review_completed", data: { review_id: "123" } },
});
```
`review_outbox` 由应用提供。Tool 可以在拿到 Turn 句柄后立即返回;运行时保存 callback 配置,执行结束后自动调用,失败只重试 callback。回调接收 `request_id`、`result`、`data`、`delivery_id`,使用 `delivery_id` 幂等消费。多个请求合并进同一 Turn 时,各自独立确认。`callback` 必须提供 `request_id`,名称必须已经在目标 Agent 注册,参数必须为 JSON;重启后重新注册相同名称并调用 `agent.resume_work()` 恢复投递。
停止旧 Turn 也会投递其 callback,结果为 `success: false`、`error: "Turn stopped"`。投递可能晚于用户新 Turn 的启动,但仍只携带旧 Turn 的结果。后续 Turn 不继承旧 callback。回调若再发起普通 prompt,仍遵守对象占用规则。
已登记的委派结果只注入原父 Turn。父 Turn 已结束时结果保存在委派记录中,不自动唤醒或注入用户后来开启的 Turn。
## 创建与打开
```ts
const created = await agent.sessions.create({
workspace,
});
const task_session = await agent.sessions.create({
workspace,
origin: { type: "task", task_id: "daily-report" },
});
const existing = await agent.sessions.get("session-id-from-list", "work");
const existing_task = await agent.sessions.get(task_session.id, "task");
```
`create()` 始终由 SDK 内部生成 Session ID。恢复已有 Session 时,从 `list()` 或其他持久化引用
取得 ID,再调用 `get(session_id, origin_type = "work")`。`origin.type` 可以是任意
非空字符串,并可携带额外 JSON 元数据;`get()` 只读取指定分区,不会扫描其他来源。
Workspace 是会话的当前目录,不是会话身份。它是 Session 的持久化配置字段,与 `model` 同属 `set()`:
```ts
const session = await agent.sessions.create({ workspace: repo_a });
await session.set({ workspace: repo_b }); // 带旧消息换到另一个仓库,session_id 不变
await session.set({ workspace: null }); // 脱离项目上下文
```
落盘的是 `workspace_id`,实例由宿主持有;恢复时由宿主解析实例并通过 `get(..., { workspace })` 注入。
`get()` 传入的 Workspace 与持久化值不同时也不会报错,而是成为该会话新的当前目录:
```ts
const reopened = await agent.sessions.get(session.id, "work", { workspace: repo_c });
```
切换目录不迁移已有消息、附件与工具结果;它们属于发生时的目录。正在执行的 Turn 沿用旧目录,
从下一个 Step 检查点起生效。没有 Workspace 的 Session 是合法的:它没有文件工具与 Shell,
但纯模型对话仍然可用。
## 管理自己的会话与委派其他 Agent
City 注册 `session` Power 管理当前 Agent 自己的会话,所有动作都不接受 `agent_id`,不能访问其他 Agent 的会话。所有 Session 类型,包括 Task 和二级 Work,都可以使用 `check` 和 `messages` 读取当前 Agent 的全部 Chat、Work、Task 会话及归档历史。独立 Agent SDK 宿主需要自行提供协作入口。项目通过 `context.workspaces.list` 选择。
| 调用方 | `start` | `send` / `reply` / `stop` / `archive` | `check` / `messages` |
| --- | --- | --- | --- |
| Chat | 可创建多个一级 Work | 仅自己的直接子普通 Work | 当前 Agent 的全部会话,包括归档会话 |
| Work(Chat 创建或用户直接创建) | 可创建多个 Subwork,支持并行 | 仅自己的直接子普通 Work | 同上 |
| 二级 Work、Task | 禁止 | 禁止 | 同上 |
| 其他自定义类型 | 默认禁止 | 默认禁止 | 同上 |
一级 Work 可以为独立工作创建多个子会话,也可以通过 `send` 继续已有子会话;数量和并发数不设固定上限,实际执行受队列及运行资源约束。归档后不能继续执行。委派深度最多为 2;读取权限不受层级限制,也不会赋予控制权限。
```js
session({
action: "start",
args: {
workspace_id: "payment-service",
query: "审查支付流程,给出问题清单和改进建议",
title: "支付流程审查"
}
})
```
`start` 还接受可选的 `model_id`,值为已配置的完整模型标识,包含提供商身份。省略时使用当前 Agent 默认模型;模型不可用时,调用明确失败。
`start` 返回受理回执,不代表工作已经完成。执行结果逐级自动回传:二级 Work → 一级 Work → Chat。一级 Work 收到子结果后汇总,再向 Chat 回报,无需轮询。
通过同一组标识继续这项工作:
```js
session({
action: "send",
args: {
session_id: "返回的 session_id",
query: "补充检查退款的幂等处理"
}
})
```
`send` 立即返回提交回执,保留目标会话的对话上下文,不创建新会话。已完成或已停止的直接子 Work 可以继续,归档会话不能继续;每次执行的结果自动回传给发起会话。
`send/reply/stop/archive` 只能控制当前 Agent 内、由调用方直接创建的普通 Work,不能控制自己、祖先、兄弟、其他 Chat 的子会话或 Task。Chat 可以读取二级 Work,但控制它需要由其直接父 Work 执行。
`reply` 只回答待处理的 `question`,先用 `check` 获取 `interaction_id`,再转交用户的真实回答。它不能代替用户审批;需要批准的操作仍由用户通过审批流程处理。看到待处理交互不代表获得回答或审批权限。
`stop` 停止目标及其子工作,并取消尚未开始的执行,保留已保存结果。子工作失败或停止后,直接父会话收到相应结果并决定后续;父工作失败时,其未完成子工作也会取消。
跨 Agent 协作由 Agent Power 提供:`agent.list` 发现、`agent.create` 创建持久 Agent、`agent.delegate` 委派独立任务。
```js
agent({
action: "agent.delegate",
args: {
agent_id: "reviewer",
workspace_id: "payment-service",
query: "审查支付流程,返回问题清单和建议",
title: "支付流程审查"
}
})
```
`agent.delegate` 必填 `agent_id`、`workspace_id`、`query`,可选 `title`、`model_id`。目标必须是其他 Agent;自身任务使用 `session.start`。回执包含 `agent_id`、`delegation_id` 和 `status`,结果自动回传到真实发起会话。委派不授予查看、继续、停止或归档对方会话的权限,当前没有跨 Agent 的会话管理动作。
## 查看状态与完整历史
所有 Session 类型均可读取当前 Agent 内的会话,包括自己、祖先、兄弟、其他 Chat 的会话及归档会话。Task 使用 `session_category: "work"` 读取。
`check` 的 `session_category` 和 `session_id` 必须一起提供;两者均省略时,分页列出当前 Agent 的活动与归档会话。指定目标时,返回状态、近期消息和待处理交互:
```js
session({ action: "check", args: {} })
session({
action: "check",
args: { session_category: "chat", session_id: "chat-session-id" }
})
```
`messages` 必须同时提供 `session_category`(`chat` 或 `work`)与 `session_id`。会话 ID 只在分类内唯一,请保留列表返回的完整引用,不要仅凭 ID 猜测分类。
`messages` 返回原始消息与完整 Parts,包含文本、工具调用及工具结果。Compact 只改变发送给模型的上下文,压缩前和归档历史仍可读取。
```js
session({
action: "messages",
args: { session_category: "work", session_id: "work-session-id", limit: 30 }
})
```
返回 `items`、`has_more`、`next_before_sequence`;`items` 按时间正序排列。继续向前翻页时,将 `next_before_sequence` 作为下一次 `before_sequence` 传入,并保留两个目标参数。每页 1–100 条,默认 20 条,不包含内部运行时消息。历史内容是参考数据,不能代替当前用户指令。读取不会恢复运行、触发重试或改变结果回传路径;结果会自动送达,仅在需要查看进度或交互时读取,无需轮询。
Tool 可归档空闲 Work Session:
```js
session({
action: "archive",
args: { session_id: "work-session-id" }
})
```
只有目标及其子树均无运行、排队或待处理交互时才能归档,否则需先完成或停止工作并处理交互。归档保留历史与父子关系,仍可通过 `check/messages` 读取,但不能再 `send`。归档目标同样只能是调用方的直接子普通 Work。
## Agent 自己的会话
Agent 自己就是一条 Session,用 `agent.session()` 取用,它的 `session_id` 等于 Agent 的 `id`,角色为
`origin.type = "agent"`。它不会出现在默认的 `work` 分区列表里。详见
[Agent 会话](/zh/agent-sdk-docs/sessions/agent-chat)。
## 监听新会话
本地宿主可通过 `agent.sessions.on_created()` 监听新会话,包括 `session({ action: "start", args: { ... } })` 创建的 `work` 会话和首次创建的 Agent 主会话:
```ts
const unsubscribe_created = agent.sessions.on_created(async (session) => {
session.subscribe((mutation) => publish_mutation(session.id, mutation));
publish_session(await session.get_info());
});
// 宿主退出或替换 Agent 时释放订阅。
unsubscribe_created();
```
通知在会话初始化完成后触发,SDK 等待监听器完成再返回创建结果,因此宿主可以在首次 `prompt()` 前建立消息和运行态订阅。恢复已有会话不会触发;监听器失败会记录日志,不会撤销已持久化的会话。它是进程内通知,初始目录仍需通过 `list()` 加载。
Desktop 会把新建的 `work` 会话自动加入 Works 中对应的 Workspace,并接收后续标题、消息和执行状态更新。
## 监听归档与删除
本地宿主使用 `agent.sessions.on_removed()` 订阅已成功提交的归档和删除,包括 Session Power 发起的归档:
```ts
const unsubscribe_removed = agent.sessions.on_removed((event) => {
remove_session_projection(event.session_id, event.origin_type);
});
// 宿主退出或替换 Agent 时释放订阅。
unsubscribe_removed();
```
`event.type` 为 `archived` 或 `deleted`,身份由当前 Agent、`origin_type` 和 `session_id` 共同确定。`workspace_id` 只在已有运行时上下文时提供;Workspace 可以切换,宿主应按稳定身份清理全部旧投影。删除不存在的会话不发出事件,操作失败不发出事件;监听器异常记录日志,不回滚提交。SDK 等待监听器处理完成后返回。这是进程内事件,不跨进程广播,也不补发历史事件。
Desktop 将事件经 IPC 转发至窗口,统一移除活动列表、清理消息和运行态缓存,并更新当前页面及导航历史。因此从 Tool 或 Desktop 操作都会同步界面。
## 列表与搜索
```ts
const page = await agent.sessions.list({
origin_type: "work",
limit: 30,
query: "repo",
});
for (const item of page.items) {
render_session_row(item);
}
if (page.has_more) {
const next_page = await agent.sessions.list({
limit: 30,
cursor: page.next_cursor,
query: "repo",
origin_type: "work",
});
}
```
`list()` 默认只查询 `work` 分区;查询其他来源时传入 `origin_type`。列表项是轻量摘要,不包含完整 Message。常用字段为 `session_id`、`origin`、`title`、`preview_text`、`message_count`、`model_label`、`updated_at` 与 `executing`。`cursor` 是透明游标,只能原样传回 SDK。
`query` 用于轻量包含匹配,适合侧边栏筛选;不要把它当作全文检索语法。
## 归档与清理
```ts
// 已持有本地 Session 句柄时,直接交给它的拥有者归档。
await session.archive();
// 也可以沿用集合入口指定 ID 和来源。
await agent.sessions.archive({ id: "repo-analysis", origin_type: "task" });
const archived_page = await agent.sessions.archived({ origin_type: "task", limit: 30 });
const removed = await agent.sessions.clean_archive();
console.log(removed.removed_session_ids);
```
归档会把 Session 及当前 Agent 下所有层级的后代 Work Session 一起从活跃列表移走,保留历史与父子关系,不按 Workspace 截断。子会话先归档,父会话最后归档,每个成功归档的节点分别触发 `on_removed`。任一节点正在执行或有排队输入时,整个操作在写入前拒绝;先等待 Turn 完成或调用 `stop()`。存储中途失败时父会话保留在活跃列表,已经归档的子会话不回滚,重试会继续归档剩余节点。
`clean_archive()` 会永久删除所有已归档 Session,适合明确的“清空回收站”操作;UI 必须在调用前要求用户确认。
本地维护还可以使用 `remove(session_id, origin_type = "work")` 永久删除一个活跃 Session 的 Agent 数据,或使用 `clear_messages(session_id, origin_type = "work")` 只清空消息。Power 自有数据不会被隐式删除;需要同时清理领域数据时,应调用对应 Power。
## UI 建议
1. 列表页只用 `list()` 的摘要数据。
2. 用户选中后调用 `get()`,再加载 `messages()`。
3. 对 `executing: true` 的条目显示运行状态;不要仅根据最后更新时间猜测。
4. 归档成功后从当前活跃列表移除该项,并重新读取归档列表。
Session 列表的标题可能为空。展示层应回退到 `session_id` 或产品定义的占位标题。
### session.set({ model })
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/set-model/
`session.set({ model })` 让本地 SDK Session 使用自己的 `ModelClient` 覆盖 Agent 模型。这里可以直接传入 Federation 目录返回的 `CityModel`,或其他实现 Downcity Model Protocol 的客户端。
```ts
await session.set({
model,
});
```
## 运行中切换何时生效
`session.set()` 成功返回表示新配置已经写入并进入当前 Session 的有序输入队列,不表示正在进行的 provider 请求会被替换。
- 当前流式响应和正在执行的工具继续使用原模型
- 新模型在下一个 Session step 检查点生效;如果当前 Session turn 先结束,则在下一个 Session turn 开始时生效
- 配置修改与运行中追加的 steer prompt 按入队顺序一起提交
- 模型切换生效时,Session timeline 会产生 completed action message
因此,配置 API 的返回值表达“修改是否成功”,action message 表达“修改在哪个 Session turn 的 step 检查点生效”。Timeline 只承担观测职责:action 写入失败不会回滚已经生效的配置,SDK 会记录 warning。
## 控制 Action 与 Mutation
`set()` 的第二个可选参数只控制配置提交后的可观测结果,不影响配置写入和检查点生效:
```ts
await session.set(
{ model },
{ persist_action: false, publish_mutation: false },
);
```
- `persist_action` 默认为 `true`;设为 `false` 时不创建包含配置 Action Part 的 Agent Message。
- `publish_mutation` 默认跟随 `persist_action`;设为 `false` 时可以保留历史 Action Part,但不向 `subscribe()` 发布对应 Message Mutation。
- `persist_action=false` 时不能设置 `publish_mutation=true`,因为 Message Mutation 必须对应 canonical Message。
- 重复设置相同模型身份不会创建新的配置 Action;重启后传入同一模型只会重新绑定运行时实例。
Session 初始化或恢复可关闭两项可观测输出;用户主动切换模型通常保留默认选项。
## 什么时候需要它
适用于这些场景:
- 你在纯 SDK 嵌入模式下使用 `@downcity/agent`
- 你直接管理本地 Session
## Agent 默认模型
多个 Session 使用同一个模型时,直接在 Agent 上提供实例:
```ts
const agent = new Agent({
id: "repo-helper",
model,
});
```
Session 没有显式模型时会回退到该 Agent 模型;设置后则优先使用自己的模型。模型实例不会持久化。
## 为什么会这样设计
因为本地 SDK 更像一个嵌入式执行壳:
- Agent 负责身份、模型、自定义工具与 Power;AgentSessions 负责 Session,Session 可选择项目工具
- 具体用哪个运行中的模型实例,由调用方决定
这让本地 SDK 更灵活,但也把“模型是否准备好”的责任明确交给了调用方。
## 如果不设置会怎样
如果 Agent 和本地 Session 都没有模型实例,执行会明确失败。
## 远程 session 呢
远程 Session 不提供模型设置 API。远程客户端只发送 prompt;服务端创建本地 Agent 时传入模型实例。
## Downcity Agent 项目呢
如果你是在正常的 Downcity Agent 项目里工作,模型通常应该来自:
- Agent config `execution.modelId`
- 已连接的 `Federation AIService`
这时不应把 `session.set({ model })` 当成默认用法。
### Session 详情
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/info/
`session.get_info()` 返回当前 Session 的轻量详情快照。它适合头部、标题栏和当前状态,不替代 `messages()`。
```ts
const info = await session.get_info();
render_header({
title: info.title || info.session_id,
preview: info.preview_text,
is_running: info.executing === true,
model: info.model_label,
});
```
## 常用字段
| 字段 | 含义 |
| --- | --- |
| `agent_id` / `session_id` | 稳定归属与身份 |
| `title` | 可为空的持久化标题 |
| `preview_text` | 适合列表的近期文本预览 |
| `message_count` | 已落盘 Message 数量 |
| `model_label` | 仅用于观测的运行时模型标签 |
| `created_at` / `updated_at` | 毫秒时间戳 |
| `executing` | 当前是否有活跃 Turn |
| `timezone` | 创建 Session 时记录的参考时区 |
`get_info()` 不返回完整历史或当前流式文本。需要会话内容使用 `messages()`;需要流式状态使用 `subscribe()`。
## 标题的实际语义
首条用户消息后,SDK 会尝试使用当前可用模型生成标题并持久化。模型不可用或生成失败时,标题可以保持为空;SDK 不会保证用首条消息自动生成 fallback。
因此 UI 必须在 `title` 为空时回退到 `session_id`、`preview_text` 或产品自己的“新会话”文案。标题更新会以 `variant: "session", type: "title"` Mutation 实时发布。
## 列表与详情的分工
- 侧边栏:使用 `agent.sessions.list()`,避免为每个条目调用 `get_info()`。
- 当前选中会话:使用 `get_info()` 更新头部和执行状态。
- 内容区域:使用 `messages()` 加载历史,使用 `subscribe()` 保持同步。
不要用 `updated_at` 代替 `executing` 判断运行状态;时间戳只说明最近写入,不说明当前是否仍在执行。
### 输入与 Turn
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/prompt-and-turns/
`session.prompt()` 是唯一的用户输入入口。它接受一条新的输入,并在 SDK 已确认该输入归属哪个 Turn 后返回 `TurnHandle`。
```ts
const turn = await session.prompt({
query: "分析这个仓库",
});
const result = await turn.finished;
if (result.success) {
console.log(result.text);
}
```
`prompt()` 不等于“等待模型最终回答”。实时过程由 `subscribe()` 接收;最终结果由 `turn.finished` 接收。
## 输入格式
`query` 可以是文本,也可以是 Downcity Session User Part 数组:
```ts
const turn = await session.prompt({
query: [
{
type: "context",
tag: "reference",
context: "此前引用的消息",
},
{ type: "text", text: "说明图片里的架构" },
{
type: "file",
media_type: "image/png",
url: "/absolute/path/architecture.png",
filename: "architecture.png",
},
],
});
```
输入与持久化消息都使用 Downcity 自有的 `snake_case` 字段。`prompt()` 接收的 Part 尚未分配 `part_id`;进入 Session 后才成为 canonical User Part。
`context` Part 会在 canonical Message 中保留结构化的 `tag` 与 `context`,进入模型上下文时才转换为 `此前引用的消息` 这样的 Text Content。标签名必须以小写字母开头,并且只能包含小写字母、数字、下划线和连字符;正文中的 XML 结构字符会被安全转义。它不属于普通用户正文,通常应和一个 `text` Part 一起使用。
Data URL 附件不会直接写入 Session Message。Session 会先把 Data URL 解码并保存到 Agent 存储作用域的
`sessions///attachments/`,Message 保存该私有文件的绝对路径。模型执行前,Session 再从该路径
读取附件并转换成模型可消费的数据格式。
创建 Session fork 时,源 Session 自己持有的本地附件会复制到新 Session 的附件目录,fork 不会继续引用源目录。因此源 Session 后续被归档或删除时,新 Session 的历史附件仍然可用。
远程 Session 的 Data URL 会在服务端保存;远程客户端传入的本地路径必须能被服务端访问。远程客户端优先使用 Data URL 或服务端可访问的 URL。
## Turn Handle
```ts
const turn = await session.prompt({ query: "继续" });
console.log(turn.id);
console.log(turn.result); // 完成前为 null
const result = await turn.finished;
```
`finished` 不会因模型或工具失败而 reject,而是总会 resolve 为:
```ts
{
turn_id: string;
success: boolean;
text: string;
error?: string;
}
```
因此应用代码必须检查 `success`,而不是只等待 Promise 成功兑现。
结构化 Agent Message 通过 `messages()` 与 `subscribe()` 读取,不在 Turn Result 中复制。模型或运行时失败时,Session 会收口 Agent Message,并使用用户可见的 Error Part 持久化失败原因。
Tool 抛出异常、返回 `{ success: false }`,或者模型生成的 Tool 参数不是完整 JSON 时,SDK 会把同一个 Tool Part 标记为 `failed`,并把 failed Tool Result 交回下一次模型 Step。它们不会直接使 Turn 失败。只有模型 Provider 流、canonical Message 写入或其他执行基础设施失败才会结束 Turn;无任何可见输出的临时 Provider 流错误会自动重试 5 次,连同首次调用最多请求 6 次。已经产生部分输出时则保留草稿并明确返回失败,避免重试造成正文重复。
每次模型请求失败都会实时发布 `warning / model_request` Mutation。`request_kind` 区分 Turn、标题和历史压缩请求;`attempt` 和 `max_attempts` 表示当前与最大调用次数,`will_retry` 表示失败后是否继续重试。主 Turn Warning 带 `turn_id`,后台 Session 请求允许不带。最终一次失败的 `will_retry` 为 `false`;用户主动停止产生的 `cancelled` 不会作为 Warning 发布。
OpenAI-compatible Provider 的 SSE 连接如果在事件中间结束,会返回可重试的 `transport_error`;完整但不是合法 JSON 的 SSE 事件会返回不可重试的 `provider_error`。对外错误只包含稳定说明,底层 JSON 解析异常不会作为用户文案暴露。
## 运行中继续输入
运行中的 Session 仍可再次调用 `prompt()`:
```ts
const first_turn = await session.prompt({ query: "先分析目录结构" });
const follow_up = await session.prompt({ query: "只看 packages/agent" });
```
Session 会自行决定第二条输入:
| 情况 | 结果 |
| --- | --- |
| 当前 Turn 仍可吸收输入 | 写入一条普通 canonical User Message,并入当前 Turn |
| 当前 Turn 已越过可吸收边界 | 写入普通 User Message,排到下一 Turn |
| Session 空闲 | 立即创建新 Turn |
两个 `prompt()` 调用可以返回同一个 `turn.id`,这表示它们被并入同一轮执行,不是重复执行。不要在客户端另建队列,也不要假设每次调用都会创建新 Turn。
## 实时显示与最终结果
典型界面同时做两件事:
```ts
const unsubscribe = session.subscribe(render_mutation);
const turn = await session.prompt({ query: "生成实现方案" });
const result = await turn.finished;
unsubscribe();
```
- `delta`:立即追加可见文本、推理文本或 Tool 输入原文。
- `part`:更新同一个工具或文本 Part 的完整状态。
- `turn.finish`:更新加载状态并与 `turn.finished` 结果对齐。
不要用 `turn.result` 轮询流式内容。它仅在 Turn 结束后变成最终快照。
## 空输入与错误处理
空字符串、空数组和只含空文本的 Part 数组会被拒绝。调用前应禁用空输入提交,调用后仍应处理错误,例如远程连接失败、模型未配置或 Session 已归档。
停止运行中的 Turn 请使用 [session.stop()](/zh/agent-sdk-docs/sessions/stop)。
### 停止 Turn
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/stop/
`session.stop()` 停止当前运行的 Turn,并取消尚未被该 Turn 吸收的排队输入。
```ts
const turn = await session.prompt({ query: "执行一个长任务" });
const stop_result = await session.stop(turn.id);
console.log(stop_result);
const result = await turn.finished;
```
## 返回结果
```ts
type AgentSessionStopResult = {
stopped: boolean;
turn_id?: string;
cancelled_queued_prompts: number;
reason: "stopped" | "idle";
};
```
| 字段 | 含义 |
| --- | --- |
| `stopped` | 是否停止了活跃 Turn 或清空了排队输入 |
| `turn_id` | 被停止的活跃 Turn;仅有活跃 Turn 时存在 |
| `cancelled_queued_prompts` | 尚未绑定到 Turn 的输入数量 |
| `reason` | `stopped` 或空闲状态的 `idle` |
## 输入队列语义
同一对象连续提交的输入由当前 Turn 消费。`stop(expected_turn_id)` 校验目标,等待停止收口并释放占用;Turn 已变化时拒绝。停止的边界是:
- 已吸收的输入随当前 Turn 一起停止。
- 尚未接收的排队输入明确拒绝,不自动启动下一 Turn。
因此“停止”按钮不会因为队列里还留有文字而立刻开始下一轮。如果产品需要保留草稿,应在调用 `stop()` 前把输入保存在 UI 草稿状态,而不是依赖 SDK 队列。
## 最终状态
被停止的 Turn 仍会完成 `turn.finished`:
```ts
const result = await turn.finished;
console.log(result.success); // false
console.log(result.error); // 通常为 Turn stopped
```
订阅流也会收到 `variant: "turn", type: "finish", status: "stopped"`。不要因为 stop 而让等待 `finished` 的 Promise 永远挂起。
空闲 Session 调用 `stop()` 不抛错,会返回 `stopped: false` 与 `reason: "idle"`,适合让 UI 的停止操作保持幂等。
### Message 与 Part
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/messages/
`session.messages()` 按 Message 边界分页读取 SQLite 中的稳定快照。实时变化由 `subscribe()` 补充。
```ts
const page = await session.messages({ limit: 50 });
for (const message of page.items) render_message(message);
```
```ts
interface SessionMessagePage {
items: SessionMessage[];
total: number;
start_sequence?: number;
end_sequence?: number;
next_before_sequence?: number;
has_more: boolean;
}
```
`total` 是 Session 已创建的真实 Message 总数,不是当前 `items.length`。`limit` 默认为 50,最大为 200。
## 两类顶层 Message
```ts
type SessionMessage = SessionUserMessage | SessionAgentMessage;
```
| `role` | 表示什么 | 顶层状态 |
| --- | --- | --- |
| `user` | 用户输入或内部恢复提示 | 无状态字段,写入即完整 |
| `agent` | Agent 输出与运行记录 | `state: "streaming" \| "done"` |
User Message 不区分 prompt 和 steer。它属于新 Turn 还是当前 Turn,由 `turn_id` 和 SessionLoop 决定,不重复编码在消息内容中。
Agent Message 的 `state` 只回答“还能否继续追加 Part”。成功、停止与失败属于 Turn 结果;需要持久化的停止或失败原因使用 `error` Part 表达。
Session 内只有一条按 `sequence` 排序的双主体消息序列。Tool、Interaction、Action 和 Error 都是 Agent Message 的 Part,不是独立 Message。普通 Tool Loop 的多个模型 Step 可以继续写入同一 Agent Message;每个模型产生的 Part 用 `step_id` 记录来源。
Action 的归属规则:目标 Turn 仍有正在流式写的 Agent Message 时,Action Part 直接追加为该 Message 的 Part,与正文共享同一条 canonical Message(例如执行中修改模型或审批模式)。只有当 Session 空闲、目标 Turn 尚未产生 Agent Message,或已有 Message 已收口时,Action 才回退为只含 Action Part 的独立 Agent Message。
## User Part
| `type` | 关键字段 | 说明 |
| --- | --- | --- |
| `text` | `text` | 用户正文 |
| `context` | `tag`, `context` | 带语义标签的模型上下文 |
| `file` | `url`, `media_type`, `filename` | 用户提交的文件 |
| `data` | `data_type`, `data`, `data_id` | 仅供宿主/UI 使用,不发送给模型 |
User Message 还可以携带一个 `meta` 字段(消息级请求记账),用于记录本条输入由谁发起以及本次请求独有的绑定:`from`(发起方,必填)、`powers`、`callback`。它不属于 Part:不进入模型上下文,也不在聊天界面上渲染,因此不会被消息编辑器还原、修改或回传。只有用户消息可能携带它。
User Part 没有流式状态。`context` 进入模型上下文时才安全转换成带标签文本:
```ts
// canonical User Part
{ part_id: "user-context:1", sequence: 1, type: "context", tag: "reference", context: "此前消息" }
// Model Content
{ type: "text", text: "此前消息" }
```
`visibility: "internal"` 使用同一个 User Message 格式。恢复提示等仅供下一 Step 使用的输入也会先持久化,再由 Composer 从 canonical history 读取;运行时没有另一套临时输入结构。
## Agent Part
| `type` | 关键字段 | 说明 |
| --- | --- | --- |
| `text` | `text`, `state` | 用户可见输出,`streaming -> done` |
| `reasoning` | `text`, `state` | 推理输出 |
| `tool` | `tool_call_id`, `tool_name`, `state`, `input`, `output` | 工具完整生命周期 |
| `interaction` | `interaction_id`, `status`, `request`, `response` | 审批、提问等异步交互 |
| `file` | `url`, `media_type`, `filename` | Agent 文件结果 |
| `data` | `data_type`, `data`, `data_id` | 结构化 UI 数据 |
| `action` | `action_id`, `action_type`, `state`, `title` | Session 操作状态 |
| `error` | `scope`, `code`, `message`, `recoverable` | Session 或 Turn 错误 |
每个 Part 有稳定 `part_id` 和不可变 `sequence`。Tool 状态变化更新同一个 Part,不创建“请求”和“结果”两条重复消息。Tool 与关联 Interaction 的状态在一个检查点事务中提交。
## 流式与持久化边界
```text
事件负责实时展示
内存负责流式组装
数据库只保存稳定语义检查点
```
模型 stream 的 start、delta 和 finish 先更新当前 Agent Message 的内存投影,并发布 `part` / `delta` Mutation;普通 chunk 不写 SQLite。一个模型 Step 完成时,SDK 校验 Part identity,并用一次事务提交完整 Step 快照。
Tool 执行前后、Interaction 状态变化、Action/Error 和 Message 收口属于不可丢失的语义检查点,可以独立提交。数据库提交后的完整 `message` Mutation 会替换流式投影。
## 顺序、版本与重连
每条 Message 有稳定 `message_id`、不可变 `sequence` 和递增的 `revision`。客户端应:
1. 先订阅并缓冲 Mutation,再读取初始快照。
2. 对完整 `message` 快照按 `message_id + revision` 合并。
3. 在流连续时应用 `part` 与 `delta`。
4. 断线后重新读取快照,不假定 Delta 可以重放。
未完成 Step 的内存投影在进程中断后可能丢弃;Session 会把遗留的 streaming Agent Message 收口为 `done` 并追加 Error Part。因此 SQLite 快照始终是重连后的权威事实。
## 加载更早历史
```ts
let page = await session.messages({ limit: 50 });
while (page.has_more && page.next_before_sequence !== undefined) {
page = await session.messages({
before_sequence: page.next_before_sequence,
limit: 50,
});
prepend_messages(page.items);
}
```
默认只返回 `visibility: "visible"` 的 Message;调试或审计时传入 `include_internal: true`。摘要属于 Composer Policy 的派生数据,不占 Message sequence,也不会出现在 `messages()` 中。
完整同步算法见 [重连与同步](/zh/agent-sdk-docs/sessions/reconnect-and-sync),存储细节见 [元数据与落盘](/zh/agent-sdk-docs/sessions/metadata-and-storage)。
### 实时订阅
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/subscribe/
`session.subscribe()` 是 Session 唯一的实时入口。它只广播订阅建立之后的变化,不缓存、不回放,也不提供背压控制。
```ts
const unsubscribe = session.subscribe((mutation) => {
apply_mutation(mutation);
});
// 不再需要时必须取消,避免重复渲染和内存泄漏。
unsubscribe();
```
订阅回调可以是异步函数。单个订阅者抛错或 reject 不会回滚已持久化状态,也不会阻塞其他订阅者。
## 八类 Mutation
所有 Mutation 都有 `mutation_id`、`session_id` 与 `created_at`。
| `variant` | `type` | 用途 | 是否带 Message revision |
| --- | --- | --- | --- |
| `message` | `user | agent` | 顶层 Message 的完整快照 | 是 |
| `part` | `text | reasoning | tool | file | data` | Assistant Part 的完整快照;`tool` Part 携带其 `interactions` | 是 |
| `delta` | `text | reasoning | tool_input` | 新增的原始文本或 Tool 输入片段 | 是 |
| `turn` | `start | finish` | Turn 执行生命周期 | 否 |
| `file_diff` | — | 当前 Turn 的文件改动实时摘要 | 否 |
| `session` | `title | config` | Session 自身属性变化 | 否 |
| `warning` | `model_request` | 模型请求失败与重试进度 | 否 |
`message`、`part` 和 `delta` 都在对应完整 Message 持久化成功后才发布。Mutation envelope 本身不写入 canonical history。
## 用 Delta 流式显示文本
```ts
session.subscribe((mutation) => {
if (mutation.variant !== "delta" || mutation.type !== "text") return;
append_text({
message_id: mutation.message_id,
part_id: mutation.part_id,
delta: mutation.delta,
});
});
```
Delta 是新增片段,不是累计全文。不要把 `delta` 当成完整 Part 覆盖 UI;也不要根据文本内容推断顺序,使用 `message_id`、`part_id` 与 `revision`。`tool_input` Delta 还会携带对应的 `tool_call_id`。
## 用完整快照纠正状态
网络、渲染批处理或 UI 切换可能让客户端错过自己维护的中间状态。`message` 与 `part` 是纠正状态的完整快照:
```ts
function apply_mutation(mutation: SessionMutation): void {
if (mutation.variant === "message") {
upsert_message(mutation.message, mutation.revision);
return;
}
if (mutation.variant === "part") {
upsert_part(mutation.message_id, mutation.part, mutation.revision);
return;
}
if (mutation.variant === "delta") {
if (mutation.type === "tool_input") {
append_tool_input_delta(
mutation.message_id,
mutation.part_id,
mutation.tool_call_id,
mutation.delta,
);
} else {
append_text_delta(mutation.message_id, mutation.part_id, mutation.delta);
}
}
}
```
客户端应保存每个 Message 已应用的最大 `revision`。低于或等于当前 revision 的 `message` / `part` 快照可以忽略,避免旧事件覆盖新状态。
## Turn 与标题
```ts
session.subscribe((mutation) => {
if (mutation.variant === "turn" && mutation.type === "start") {
set_turn_running(mutation.turn_id);
}
if (mutation.variant === "turn" && mutation.type === "finish") {
set_turn_finished(mutation.turn_id, mutation.status, mutation.error);
}
if (mutation.variant === "session" && mutation.type === "title") {
set_session_title(mutation.title);
}
});
```
`turn.finish` 的状态为 `completed`、`failed` 或 `stopped`。它和 `turn.finished` 共同描述同一最终结果:前者适合订阅 UI,后者适合控制流。
上下文压缩不是独立 Mutation,也没有对应的 public Handle。`SessionComposer.advance_context()` 的执行过程写成 Action Part,`action_type` 为 `context-compaction`:确认有可压缩内容后,先发布 `running`,再使用同一个 Action ID 更新为 `completed` 或 `failed`。订阅消息即可在摘要生成期间显示正在整理上下文;没有可推进内容时不产生 Action。自定义 Composer 应在确认有效推进区间后、开始耗时工作前调用并等待 `input.on_advance_start?.()`,以发布运行中状态。它不会改写 canonical 历史,只推进 Composer 的派生表(见 [元数据与落盘](/zh/agent-sdk-docs/sessions/metadata-and-storage))。
模型调用失败时,`warning / model_request` 会携带 `request_kind`、`code`、`message`、`retryable`、`attempt`、`max_attempts` 与 `will_retry`。主 Turn 请求还携带 `turn_id`,后台标题与历史压缩请求允许不带。它是实时诊断,不写入 Message 历史;`retryable` 表示 Provider 的错误分类,`will_retry: false` 表示本次失败不会再自动重试。
## 工具 Part 的状态机
同一个 Tool Part 以同一个 `part_id` 更新:
```text
input-streaming -> ready -> waiting-user -> running -> completed
\-> failed
ready ----------------------------------------------> running
```
`input-streaming` 表示模型仍在输出工具参数。此阶段参数原文通过 `tool_input` Delta 追加到 Tool Part 的 `input_text`;`ready` Part 是输入完整后的校准快照,并携带解析完成的 `input`。UI 应以 `tool_call_id` 或 `part_id` 做稳定 key,不要为每个 Delta 新增一项。
如果参数流结束时不是有效 JSON,同一个 Part 会直接从 `input-streaming` 进入 `failed`,`error` 会说明输入解析失败;Tool 实现不会执行。Tool 正常返回结构化 `{ success: false }` 时也会进入 `failed`。客户端只应根据 Part 的 `state` 和 `error` 展示结果,不要根据输出文本猜测成功与否。
审批的处理方式见 [工具审批](/zh/agent-sdk-docs/sessions/approvals)。
## 生命周期边界
`unsubscribe()` 只停止未来事件,不会停止 Session、Turn 或工具。路由切换、组件卸载和 Session 切换时都应调用它;切换回该 Session 后重新加载快照并重新订阅。
断线恢复不能只靠重新订阅,见 [重连与同步](/zh/agent-sdk-docs/sessions/reconnect-and-sync)。
### 重连与同步
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/reconnect-and-sync/
`subscribe()` 只发送未来变化,`messages()` 只返回读取瞬间的快照。把两者按错误顺序组合会产生竞态:先读快照再订阅时,快照完成前发生的 Delta 可能丢失。
可靠流程是“先订阅并缓冲,再读快照,再合并缓冲事件”。
## 推荐算法
```ts
const buffered: SessionMutation[] = [];
let snapshot_ready = false;
const unsubscribe = session.subscribe((mutation) => {
if (!snapshot_ready) {
buffered.push(mutation);
return;
}
apply_mutation(mutation);
});
const [message_page, info] = await Promise.all([
session.messages(),
session.get_info(),
]);
replace_message_state(message_page.items);
replace_session_info(info);
for (const mutation of buffered) {
apply_mutation(mutation);
}
snapshot_ready = true;
```
真实 UI 应把 `buffered` 的处理与 `snapshot_ready = true` 放在同一个串行状态更新中,避免缓冲清空与新事件到达之间再出现窗口。
## 合并规则
| Mutation | 合并方式 |
| --- | --- |
| `message` | 按 `message_id` upsert,仅应用更高 `revision` |
| `part` | 在对应 Assistant Message 内按 `part_id` upsert,仅应用更高 `revision` |
| `delta` | 向对应 text/reasoning 的 `text` 或 Tool 的 `input_text` 追加 `delta` |
| `turn` | 按 `turn_id` 更新运行状态 |
| `session` | 更新当前 Session 元数据,例如标题 |
断线后不要假定 Delta 可以重放。已完成 Step 的 Delta 已合并进 SQLite 稳定快照;尚未完成 Step 的投影可能因中断而丢弃,并以 Error Part 收口。因此重新读取 `messages()` 才能恢复权威状态。
## RemoteSession 断线
远程连接关闭后:
1. 保留当前 UI 快照作为临时展示,不把它当最终事实。
2. 清理旧订阅。
3. 建立新订阅并暂存 Mutation。
4. 重新读取 `messages()` 与 `get_info()`。
5. 按上面的合并规则应用暂存事件。
如果断线时本地仍有 `turn.finished` 等待者,应用层应处理其失败结果,并通过重新读取 Session 恢复界面。不要凭客户端计时器合成 Assistant 完成状态。
## 历史分页
首屏读取最近一页 Message。需要更早历史时,使用当前结果的 `next_before_sequence` 并保留合适的 `limit`。实时 Mutation 更新当前快照;不要让较旧 revision 覆盖已加载 Message。
## 去重
同一个连接的事件通常有序,但客户端仍应做到幂等:
- 用 `message_id + revision` 排除旧快照。
- 对 Part 使用所属 Message 的 revision。
- 对 Delta 只在流仍连续时追加;`tool_input` 通过 `tool_call_id` 校验目标 Tool;重连后以快照为准。
- 可用 `mutation_id` 去重同一个事件的重复投递。
这套方式适用于本地与远程 Session。区别仅在远程 transport 可能断开,本地回调通常不会。
### 用户异步交互
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/approvals/
Interaction 表示当前 Turn 的执行需要用户参与。第一阶段支持 `approval` 与
`question`;它们使用同一套生命周期、查询和响应 API。
Interaction 是 Assistant Message 的 Part,不是新的顶层 Message,也不会创建新 Turn。
当 Interaction 来自 Tool 时,Tool 与 Interaction 在同一个 Assistant revision 中原子更新:
```text
创建:Tool ready -> waiting-user + Interaction pending
响应:Interaction resolved + Tool running / failed
结束:Interaction cancelled + Tool failed
```
## 订阅并响应
```ts
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (!interaction) return;
render_interaction(interaction);
});
await session.respond({
interaction_id,
response: {
type: "approval",
outcome: "resolved",
payload: { decision: "approved" },
},
});
```
`subscribe()` 只报告已经持久化的状态。`respond()` 先原子提交 Interaction 与关联 Tool,
成功后才恢复原执行。请求不存在、已经终结或响应类型不匹配时会抛出明确错误。
## 查询 pending Interaction
```ts
const pending = await session.interactions();
for (const request of pending) {
if (request.type === "approval") {
render_approval(request);
} else {
render_questions(request.payload.questions);
}
}
```
`approval` 请求同时携带 `response_schema`,声明响应必须满足的结构;Shell 审批与 Tool
审批共用同一份默认 schema,可直接从 SDK 导入 `SESSION_APPROVAL_RESPONSE_SCHEMA`
(要求 `decision` 为 `approved` 或 `denied`)。
Question 支持 `text`、`single_select` 与 `multi_select`。回答必须覆盖请求中的全部
`question_id`:
```ts
await session.respond({
interaction_id,
response: {
type: "question",
outcome: "resolved",
payload: { answers: [
{ question_id: "region", value: "cn" },
{ question_id: "features", value: ["search", "files"] },
] },
},
});
```
## 模型主动提问
`ask_question` 是可选 Tool,不会由 Agent 默认注册。需要该能力时由调用方显式传入:
```ts
import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { AskQuestionsTool } from "@downcity/agent/tools";
const agent = new Agent({
id: "repo-helper",
tools: {
ask_question: AskQuestionsTool,
},
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});
```
模型缺少会实质影响结果的信息时,返回标准 Tool Call:
```json
{
"title": "选择部署区域",
"questions": [
{
"question": "需要部署到哪个区域?",
"type": "single_select",
"options": [
{ "value": "cn", "label": "中国" },
{ "value": "us", "label": "美国" }
]
}
]
}
```
`questions` 中每个问题都必须显式提供 `type`,可选值为 `text`、`single_select` 和
`multi_select`。`question_id` 由 Session 自动生成;选择题必须提供至少一个同时包含
`value` 和 `label` 的 `options`,文本题不需要 `options`。
Session 会把该 Tool 与 Question Interaction 原子提交为 `waiting-user` / `pending`。调用方
使用上面的 `session.respond(...)` 提交完整回答后,Tool Result 返回:
```json
{
"status": "resolved",
"answers": [{ "question_id": "question:generated-id", "value": "cn" }]
}
```
模型随后在同一个 Turn 的下一 Step 继续执行。普通 assistant 文本中的问句不会进入该
生命周期:它会正常结束当前 Turn,用户的下一条消息会创建新 Turn。
## 普通 Tool 审批
需要用户许可的 AgentTool,在自己的执行里向 Session 的审批端口请求审批:
```ts
import { define_agent_tool } from "@downcity/type";
import { z } from "zod";
const delete_file = define_agent_tool({
description: "删除工作区内的指定文件。",
input_schema: z.object({
path: z.string(),
}),
execute: async ({ path }, context) => {
// 审批端口由 Agent 在执行时注入;ask 模式等待用户,always-allow 直接放行。
const decision = await context.interactions.approval.request({
turn_id: context.turn_id,
tool_call_id: context.tool_call_id,
tool_name: "delete_file",
payload: { operation: "tool", validated_input: { path } },
});
if (!decision.approved) {
return { success: false, error: "denied" };
}
await remove_workspace_file(path);
return { deleted: path };
},
});
const agent = new Agent({
id: "repo-helper",
model,
tools: { delete_file },
});
// Session 在创建时选择 Workspace。
```
审批策略属于 Tool,不属于 Agent。模型产生 Tool Call 后,Agent 先完成 schema 校验,
随后进入 Tool 自己的 `execute`;Tool 在这里通过审批入口创建 `operation: "tool"` 的
Approval Interaction,并等待 `session.respond(...)`。批准后继续执行副作用,拒绝时不再执行。
内核不再有工具级审批钩子:工具自己决定「哪一步需要有人拍板」,Session 决定「这个拍板
怎么回答」。审批模式(`ask` / `always-allow`)在审批入口内生效,Tool 不自己读模式;
`always-allow` 时不创建 Interaction,直接返回放行。
`interactions.request()` 是通用原语,`interactions.approval` 是审批专用入口。发起任意交互
只需提供语义字段,`interaction_id` / `created_at` / `source` 由原语补齐:
```ts
const handle = await session.interactions.request({
type: "question",
turn_id,
tool_call_id,
payload: { questions },
});
const result = await handle.result; // 生产者自己解释 payload
```
审批请求向 UI 提供真实调用信息:
- `source.tool_name` 与 `source.tool_call_id`:实际 Tool 及调用标识。
- `payload.validated_input`:schema 校验通过后的结构化输入。
- `payload.tool_description`:Tool 定义中的稳定能力说明,可选。
- `payload.model_explanation`:模型在当前调用中生成的解释,可选,不能作为安全判断依据。
调用方不需要配置审批 `title` 或 `reason`。UI 应根据 Tool、已校验输入和可选模型解释
组织展示内容,不能用客户端生成的摘要替代真实输入。
## Shell 审批模式
```ts
const current = await session.status();
await session.set({
security: {
approval_mode: "always-allow",
},
});
```
| 模式 | 行为 |
| --- | --- |
| `ask` | host Shell 请求创建 approval Interaction。 |
| `always-allow` | 当前 Session 后续 Shell 请求自动批准。 |
审批模式是 Shell Adapter 的执行策略,不是通用 Interaction 状态。它不改变 sandbox,
不影响其他 Session,也不会处理已经 pending 的 Interaction。
`set()` 会接受 configured 值,并把一个 `SessionCommand` 加入 Session 有序输入队列。
当前 Provider 请求与 Tool callback 继续使用已经捕获的策略,新模式在下一个 Session Step
检查点生效。如果当前 Turn 没有下一个 Step,该命令会在下一条 prompt 开始前提交。
通过 `status()` 读取状态:`security.approval_mode` 是 configured 值,
`security.effective_approval_mode` 是执行面当前值;两者不同时表示修改仍在排队。Command
成功提交后,Session 会持久化一条 `Session configuration updated` completed Action。
初始化或恢复审批模式时,可以通过 `set()` 的第二个参数关闭 Action 和 Mutation;配置仍会正常写入并在检查点生效:
```ts
await session.set(
{ security: { approval_mode: "always-allow" } },
{ persist_action: false, publish_mutation: false },
);
```
相同审批模式的幂等设置不会生成新的配置 Action。审批模式会随 Session metadata 恢复。
## UI 实现要点
- 使用 `interaction_id` 作为交互卡片稳定 key。
- 任何带有 `source.tool_call_id` 的来源,都用该 Tool Call ID 关联 Tool Part。
- Tool 为 `waiting-user` 时保持等待态,不要在客户端伪造 `running` 或成功。
- `resolved`、`denied`、`cancelled`、`failed` 都是终态,不能重复响应。Interaction 没有超时:它只能由用户响应,或随 Turn/Session 结束而被取消。
- Session 切换时清理旧交互 UI,不能把旧 Session 的 `interaction_id` 发给新 Session。
完整 Part 状态见 [Message 与 Part](/zh/agent-sdk-docs/sessions/messages)。
### session.system()
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/system/
## 示例
```ts
const system = await session.system();
```
## 它返回什么
返回结构化 system snapshot:
```ts
{
session_id: string;
session: {
agent_id: string;
session_id: string;
project_root: string;
created_at: string;
timezone: string;
};
blocks: Array<{
source: "core" | "instruction" | "power" | "session";
name: string;
content: string;
}>;
}
```
这些 blocks 可以包括:
- Agent 身份块,携带 Agent 的 `name` 与 `id`
- 调用方传入的静态 `instruction`
- Downcity Agent core 说明,包括 Harness 设计思路、Shell command 规则和 Power 系统总规则
- 已注册并启用的 Power system 文本
- 稳定的 session 上下文,包括 `session_id`、`project_root`、创建时间与时区
返回内容和 `session.prompt()` 使用的是同一套稳定 system blocks。system 第一次生成后会固定在当前 Session 内存中;后续 Agent instruction 或 Power registry 变化不会修改它。新 Session 才会使用最新配置生成 system。
需要让已有本地 Session 显式采用 Agent 当前配置时,调用 [`session.syncshot()`](/zh/agent-sdk-docs/sessions/syncshot)。
session 创建时间和时区是当前会话的参考时间上下文。当前时间等动态值应放入 user message,不应放进 `instruction`。
## Block 顺序
`@downcity/agent` 会按固定顺序构造 system blocks:
1. `instruction` / `identity`:由 SDK 注入的 Agent `name` 与 `id`。同一个 Agent 的所有 Session 共享这份身份。
2. `instruction` / `agent`:宿主传入的 Agent 角色、业务规则和回复偏好。
3. `core`:Downcity Agent 固定运行说明,包括 Harness 设计思路、Shell command 与 Power 系统总规则。
4. `power`:每个已经启用、已 ready 且当前可用的 Power 自己的 system prompt。
5. `session`:当前 session 的稳定上下文。
即使宿主传入了 `instruction`,`core` 也会继续注入,并紧跟在 `instruction` 后面。`session` 块只描述 session 自身,不重复 Agent 身份。
## 示例快照
```json
{
"session_id": "design-review",
"session": {
"agent_id": "reviewer",
"session_id": "design-review",
"project_root": "/path/to/project",
"created_at": "2026-05-19T14:16:25.048Z",
"timezone": "Asia/Shanghai"
},
"blocks": [
{
"source": "instruction",
"name": "identity",
"content": "# Agent Identity\n\nYou are \"代码审查员\" (agent id: reviewer)."
},
{
"source": "instruction",
"name": "agent",
"content": "你是 Downcity 的项目助手。"
},
{
"source": "core",
"name": "default",
"content": "# Downcity Agent\n\nYou are a Downcity Agent running inside the Downcity Agent runtime...\n\n# Harness Design\n\nDowncity Agent is a harness around an existing human-owned workspace, not a replacement platform...\n\n# Shell Commands\n\nAll Shell work goes through the `shell` power...\n\n# Power System\n\nYou are working in a power-based execution environment..."
},
{
"source": "power",
"name": "task",
"content": "# Task Power\n\nYou can use the `task` power to manage task definitions, execution, scheduling, and status control..."
},
{
"source": "session",
"name": "context",
"content": "Current session context:\nThis session is \"design-review\".\nThe current project root is \"/path/to/project\".\nThis session was created at 2026-05-19T14:16:25.048Z, with Asia/Shanghai as its reference timezone.\nThis creation time is a stable reference for the session and does not represent the current time for every run.\nIf the user message provides a newer current time, a relative time, or other dynamic context, prioritize the dynamic information from the user message."
}
]
}
```
## 它不会做什么
`session.system()` 不会把 system prompt 写进 canonical Message history。
Session history 仍然只关注可见的 user / assistant 消息。如果你需要检查当前会随历史一起进入模型的提示词上下文,用 `system()`。
### session.syncshot()
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/syncshot/
本地 Session 的 system 第一次生成后会固定在内存中。需要显式采用 Agent 当前最新 instruction、managed system blocks 和 Power system 时,调用:
```ts
await session.syncshot();
```
`syncshot()` 会替换当前 Session 的内存 system snapshot。已经发给 provider 的请求不受影响,后续 step 使用新 system。它只刷新 system,不改变 Session 的 Power execution view。
持久化规则取决于当前 Session 是否已经存在 `instruction.md`:
- 文件已存在:原子覆盖为重新生成后的完整 system。
- 文件不存在:只更新内存,不自动创建文件。
如果需要首次创建 `instruction.md`,在刷新后显式调用:
```ts
await session.syncshot();
await session.snapshot();
```
`syncshot()` 和 `snapshot()` 会在同一 Session 内串行执行,避免并发写入覆盖顺序不确定。该方法只属于本地 `AgentSession`,远程 Session 不提供。
### 分叉 Session
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/fork/
`session.fork()` 从当前 Session 的历史创建一个独立的新 Session。分叉后的后续输入、模型执行、工具状态与审批都彼此隔离。
```ts
const full_branch = await session.fork();
const branch_at_message = await session.fork({
message_id: "assistant:repo-analysis:abc",
});
```
也可以直接传入 `message_id` 字符串:
```ts
const branch = await session.fork("assistant:repo-analysis:abc");
```
## 原地替换历史
传入 `replace: true` 会直接截断当前 Session 的历史并返回当前实例,保留 Session ID、配置及保留消息的身份,不创建分支、不归档,也不写入操作记录。
```ts
await session.fork({
message_id: "user:question:abc",
include_message: false,
replace: true,
});
await session.prompt({ query: "修改后的问题" });
```
`include_message` 默认 `true`;设为 `false` 时同时移除锚点消息。后续历史会被永久删除,关联的上下文摘要也同步清理。未指定 `message_id` 时历史保持原样。Session 正在执行或有排队命令时拒绝替换。
订阅方会收到 `variant: "session"`、`type: "truncate"` 的同步事件,通过 `through_sequence` 移除后续消息;该事件不应显示为时间线操作记录。本地与远程 Session 均支持此参数。
## 历史边界
| 调用 | 复制范围 |
| --- | --- |
| `fork()` | 当前可见历史的完整状态 |
| `fork({ message_id })` | 从开头复制到该 Message,包含该 Message |
| `fork({ message_id, include_message: false })` | 从开头复制到该 Message 之前 |
分叉点是顶层 Message,不是 Assistant 内的 `part_id`。如果需要在工具调用前后试验不同方向,先在 `messages()` 中找到对应 Assistant Message 的 `message_id`。
## 来源与顺序
复制进新 Session 的 Message 保留新的 Session 身份和新的线性顺序,同时可携带 `origin`,记录来源的 `session_id`、`message_id` 与 `turn_id`。这使分叉可追溯,但分叉后两个 Session 不共享可变状态。
分叉成功后,新 Session 时间线末尾会记录一条 `history-fork` Action,说明这段历史的来源。源 Session 不会留下任何分叉记录。
不要把分叉理解为“两个窗口共同编辑一份历史”。源 Session 的后续 Message 不会自动出现到分叉中,分叉的后续 Message 也不会回写源 Session。
## 适合的场景
- 在保留主线结论的前提下比较两种实现方案。
- 从某一条用户输入重新提问,避免后续上下文干扰。
- 为长任务建立实验支线,而不污染主会话。
分叉后按普通 Session 使用:加载 `messages()`、建立 `subscribe()`,然后调用 `prompt()`。不需要复制 Mutation,也不应该复制源 Session 的 pending approval。
### 元数据与落盘
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/metadata-and-storage/
SDK Session 保存在 City 提供的 Agent 存储作用域中;没有 City 时默认使用内存存储。应用应通过
Session API 读写,不要把这些文件当作自己的数据库。
```text
~/.downcity/agents//sessions///
├── session.db
└── attachments/
```
一个 Session 对应一个 `session.db`。核心表为:
- `session_state`:Session 身份、当前目录、配置、标题、预览和 system snapshot。
- `messages`:User/Agent Message 的 envelope。
- `message_parts`:每个 Message 的独立 Part;一个 content 对应一个 Part。
读取 API 返回聚合后的 `SessionMessage`,数据库表结构不会泄漏到 UI 或模型 Provider。Message 是
canonical history,永远不会因为上下文压缩而删除或改写。
`session_state.workspace_id` 记录的是该会话的当前目录,不是身份:它可以在会话中改变,
改变不影响 `session_id`、历史与附件归属。Agent 自己的那段对话位于固定的 `main` 分区,
路径为 `sessions/main/main/`。
## Composer 与派生上下文
`SessionComposer` 负责生成最终送给模型的 system、messages 和 tools。调用方把 canonical history 与带
命名空间的派生存储传入每次调用,Composer 不会自己去打开 Session 存储。
默认 `DefaultSessionComposer` 以 Part 为上下文处理边界,并在自己的
`composer_adaptive_part_checkpoints` 派生表中保存累计摘要边界。它的 `compose()` 返回:
```text
显式 system block
+ 边界之后的 canonical Parts
```
摘要与其他投影出的上下文都是可重建的派生数据,不是 Message。模型返回上下文超限,或真实 usage 超过
压力阈值时,Session 通过 `advance_context()` 请求 Composer 推进派生状态;不会写入 Active、Segment 或第二份消息文件。
## 中断恢复与事务
Message envelope 与 Parts 在同一个 SQLite 事务中提交,只有提交成功后才发布 Mutation。启动时
SessionMessages 会把 Storage 查询到的 streaming Agent Message 和 running Action 收口为可恢复状态;Storage 不解释业务终态。附件仍放在
`attachments/`,Message Part 只保存附件引用。
模型流遵循“事件负责实时展示、内存负责流式组装、数据库只保存稳定语义检查点”:普通 chunk 不写 SQLite;完整模型 Step、Tool 执行前后、Interaction、Action/Error 和 Message 收口才提交事务。
旧版本 JSONL 数据不由 Desktop 或 Runtime 自动迁移。需要迁移时,在应用外执行仓库提供的一次性脚本
`scripts/migrate-session-storage-to-sqlite.mjs`。已经生成旧 SQLite 的开发期数据使用
`scripts/migrate-session-sqlite-v3.mjs` 升级;Runtime 只检查版本,不会隐式修改磁盘。Interaction 并入
Tool Part 之前生成的数据库仍保留独立的 Interaction 行,读取时会被拒绝;执行
`scripts/migrate-session-interaction-into-tool.mjs` 把它们移回所属 Tool Part。
### 构建聊天界面
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/build-a-chat-ui/
一个可用的 Session 聊天界面不需要维护第二份 Timeline。它只维护由 `SessionMessage` 构成的线性状态,并把 Mutation 合并进这份状态。
## 初始化
先订阅并缓冲,再加载快照,避免启动时丢失 Delta:
```ts
const buffered: SessionMutation[] = [];
let ready = false;
const unsubscribe = session.subscribe(async (mutation) => {
if (!ready) {
buffered.push(mutation);
return;
}
await apply_live_mutation(mutation);
});
const [page, info] = await Promise.all([
session.messages(),
session.get_info(),
]);
set_messages(page.items);
set_session_info(info);
for (const mutation of buffered) {
await apply_live_mutation(mutation);
}
ready = true;
```
完整竞态处理见 [重连与同步](/zh/agent-sdk-docs/sessions/reconnect-and-sync)。
## 提交与停止
```ts
async function submit(query: string): Promise {
const turn = await session.prompt({ query });
set_active_turn(turn.id);
const result = await turn.finished;
clear_active_turn(turn.id);
set_turn_status(turn.id, result.success ? "completed" : "failed");
}
async function stop(): Promise {
const result = await session.stop();
if (result.cancelled_queued_prompts > 0) {
show_notice("未开始的输入已取消");
}
}
```
发送按钮应在输入为空时禁用;停止按钮应由 `turn` Mutation 或 `get_info().executing` 驱动。
## 渲染规则
- 按顶层 Message 的 `sequence` 渲染对话。
- Assistant 内按 Part 的 `sequence` 渲染文本、工具、再文本。
- 一个 Assistant Message 只渲染一次角色标题;Tool Loop 和 Provider continuation 产生的新 Part 继续留在同一角色容器中。
- `text` / `reasoning` Delta 追加到当前文本 Part,`tool_input` Delta 追加到当前 Tool Part 的 `input_text`。
- `part` 用稳定 `part_id` 覆盖完整状态。
- `message` 用 `message_id + revision` 覆盖完整 Message。
- 已收口 Agent Message 内的 Error Part 是一次失败唯一的用户可见错误提示。
- 所属 Tool Part 内的 Interaction 为 `pending` 时显示交互控件;该 Tool 为 `waiting-user` 时显示等待状态。
不要创建 Transcript Message DTO 或把工具调用平铺到第二份可持久化列表,也不要用最终 `turn.text` 覆盖已经按 Part 渲染的 Agent Message。`turn.finished.error` 只用于控制流和日志;若已经渲染 Error Part,不要再追加一条错误提示。
## 审批卡片
审批 UI 从所属 Tool Part 上的 pending Interaction 或 `session.interactions()` 得到相同身份:
```ts
await session.respond({
interaction_id,
response: { type: "approval", outcome: "resolved", payload: { decision: "approved" } },
});
```
提交后保持等待下一条 Part Mutation。只有 SDK 发布 `running`、`completed` 或 `failed` 后才改变工具展示状态。
## Session 切换与卸载
切换会话时按这个顺序:
1. `unsubscribe()` 旧会话。
2. 清空旧会话的消息、Turn 与审批 UI 状态。
3. 取到新 Session 后执行初始化流程。
4. 不复用旧会话的 `interaction_id`、`part_id` 或 Turn loading 状态。
这是避免“审批提交给错误会话”和“历史滚动显示旧 Delta”的必要边界。
### session.snapshot()
Source: https://downcity.ai/zh/agent-sdk-docs/sessions/snapshot/
本地 Session 第一次生成 system 后会在内存中固定这份结果。Agent instruction 或 Power registry 后续发生变化时,已有 Session 的 system 不会改变;新 Session 才会使用最新配置生成自己的 system。
调用 `snapshot()` 可以显式把当前 Session 的完整 system 固化到 `instruction.md`:
```ts
await session.snapshot();
```
文件位置:
```text
~/.downcity/agents//sessions/