# 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 your first agentInstall Downcity and start running agents on your own machine. Understand the architectureSee how Agent, City, and Federation work together. Build with the SDKBring agents and sessions into your own application. Connect tools and capabilitiesConfigure tasks, memory, web access, and other Powers.
## 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 `![](path)`. 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 私有目录并返回本地路径,在回复里用 `![](path)` 引用即可。 入站语音消息的自动转写属于 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///instruction.md ``` 恢复 Session 时,SDK 优先读取这个文件;文件不存在时使用 Agent 当前 instruction 和 Power 重新生成。删除 `instruction.md` 即可取消持久化,后续恢复会重新生成。 `snapshot()` 保存完整 system,包括 Agent 身份块、自定义 instruction、SDK core、Power system 和 Session context,但不包含消息历史、模型或环境变量。多个 system block 会按原顺序合并为一个 Markdown 文档;再次调用会原子覆盖现有文件。 需要先从 Agent 当前配置重新生成再持久化时,依次调用 `await session.syncshot()` 和 `await session.snapshot()`。如果 `instruction.md` 已经存在,`syncshot()` 自身也会同步覆盖该文件。 该方法只属于本地 `AgentSession`。远程 Session 的 instruction 持久化策略由服务端宿主管理。 ### 嵌入到 Node 应用 Source: https://downcity.ai/zh/agent-sdk-docs/integration-patterns/embed-in-node-app/ 这是最自然的 SDK 使用方式: - 你的应用代码直接创建 `Agent` - 应用启动时准备 session - handler 收到请求后转成 `session.prompt()` 适合: - 内部工具 - MCP 风格包装层 - 面向项目的研发助手服务 这种模式最大的优点是: - 上下文最完整 - 调试最直接 - 不需要先跨进程排查网络问题 ## 打包行为 当前 `@downcity/agent` 的内建 system prompt 与初始化模板已经直接编译进 TS 模块。 这意味着: - 不需要额外复制 `PROMPT.txt` 之类的运行时文本资源 - 把 SDK 嵌入 Electron main 或其他 Node 宿主时,不再依赖包目录中的 prompt 文件结构 - 只要你的运行时能正确加载 `@downcity/agent` 的 ESM 入口,就不会再因为 prompt 资产路径丢失而失败 ### Agent 与 Federation AIService Source: https://downcity.ai/zh/agent-sdk-docs/integration-patterns/agent-with-city-ai-service/ 如果你在正常的 Downcity 项目里使用 Agent,模型通常不应该由 Agent 项目自己持有。 这时要把职责拆成四层: - `Federation AIService`:拥有模型目录、AIChannel、API key、模型路由 - `Downcity`:连接 City,读取模型目录,校验和解析 `modelId` - `Agent 项目`:只声明 `execution.modelId` - `@downcity/agent` session:真正执行 prompt ## 最重要的结论 在 Downcity 集成模式里: - CLI 全局数据库保存的是 `modelId` - 不是 provider 配置 - 不是 API key - 也不是本地模型实例 也就是说,下面这种心智模型才是对的: ```text global downcity.db: agent.execution.modelId -> Downcity 读取并校验 -> City SDK 创建可执行 CityModel -> Agent 通过 Downcity Model Protocol 调用 CityModel.stream() -> Federation AIService 路由到 AIChannel 与上游协议 ``` ## 为什么要这样分层 这样做的好处是: - 多个 Agent 可以复用同一套模型目录 - Channel key 不进入项目目录 - Channel 切换、模型增删、默认模型策略都可以在 City 侧统一管理 这也是 Downcity 当前真实实现的边界: - Downcity 不拥有模型池 - Downcity 不解析 Channel、apiKey 或 base URL - 模型目录唯一来源是 Federation AIService ## Agent 项目里实际写什么 项目里真正的绑定点是: ```json { "execution": { "type": "api", "modelId": "quality" } } ``` 也就是: - `execution.type = "api"` - `execution.modelId = ""` ## City 侧如何提供模型 City 侧通常会这样注册模型: ```ts import { Federation, AIService, AIChannel, stream_openai_compatible_model, type AIChannelStreamInput, type AIChannelStreamResult, } from "@downcity/federation"; const base = new Federation({ database }); class DeepSeekChannel extends AIChannel { constructor() { super({ id: "deepseek", base_url: "https://api.deepseek.com/v1", env_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: "quality", upstream_model: "deepseek-chat", name: "DeepSeek Quality", }), ); base.use(ai); ``` 注册后,`Federation AIService` 就拥有了: - 模型目录 - Downcity Model Protocol 通路 - CityModel 原生 `ModelClient` 通路 - OpenAI 兼容通路 ## Downcity 如何读取和校验模型 Downcity 启动 Agent 项目前,会去 Federation AIService 读取模型目录并校验 `modelId`。 当前真实实现大致是: 1. 读取 City 连接配置 2. 通过 Embassy User 调 `embassy.user.ai.catalog()` 3. 检查 `execution.modelId` 是否存在 4. 从目录取得已实现 `ModelClient` 的 `CityModel` 这也是为什么: - `execution.modelId` 写错时,问题通常会在启动或首次执行阶段暴露 - 而不是拖到对话跑了很多轮才发现 ## 什么时候该用 `session.set({ model })` ### 应该用 适用于纯 SDK 嵌入模式: - 你自己在 Node 应用里 `new Agent(...)` - 你自己持有模型实例 - 你不是通过 Downcity 去绑定 City 模型 ### 不应该作为默认路径 在正常的 Downcity Agent 项目里,不应该把 `session.set({ model })` 当默认用法。 这时应优先: - 在 Federation AIService 注册模型 - 在项目里写 `execution.modelId` ## 一个完整的最短链路 ### 1. City 注册模型 ```ts const ai = new AIService(); ai.use(channel.model({ id: "quality", upstream_model: "vendor-quality", name: "Quality" })); base.use(ai); ``` ### 2. Downcity 连接 City ```bash downcity federation use downcity federation status ``` ### 3. Agent 项目绑定 modelId ```json { "execution": { "type": "api", "modelId": "quality" } } ``` ### 4. 启动或重启 CLI City ```bash downcity restart ``` ## 如果要远程访问 Agent 要区分两层: - 模型能力:来自 Federation AIService - session 能力:由本地 Agent 对外暴露 所以 `RemoteAgent` 连到的是: - 本地 Agent 暴露出的 session API 不是直接去连 Federation AIService 模型本身。 ## 常见误区 ### 误区 1:Agent 项目里应该保存 Channel 配置 不是。项目里只保存 `modelId`。 ### 误区 2:RemoteAgent 可以远程传 `session.set({ model })` 不是。远程 session 当前不支持客户端直接传本地模型实例。 ### 误区 3:Downcity 自己维护模型目录 不是。Downcity 只读取和校验 Federation AIService 暴露出的模型目录。 ## 相关文档 - [Federation AIService](/zh/city-sdk-docs/service/ai/index) - [添加模型](/zh/city-sdk-docs/quickstart/add-model) - [模型配置(City + Agent)](/zh/docs/configuration/model) - [把模型绑定到 Agent](/zh/docs/agent/configuration/models/bind-model-to-agent) ### 一个仓库里多个 Agent Source: https://downcity.ai/zh/agent-sdk-docs/integration-patterns/multi-agent-in-one-repo/ 如果你在同一个项目目录里创建多个本地 SDK Agent,最重要的是: - 给每个 agent 一个稳定且不同的 `id` 例如: ```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 }); ``` 两个 Workspace 可以指向同一物理目录。Session 存储按 `agent_id` 分区,每个 Session 在创建时显式选择 Workspace。如果两个 Agent 需要彼此隔离的命令环境,应分别为 Workspace 创建和传入 Shell 实例。 ## 使用决策模型组织 Group Group 每轮只选择需要回复的成员,选中的成员并行执行。所有成员结束后,将整轮回复写入共享上下文,再调用决策模型;返回空成员集合时回到空闲。Group 不使用语言模型生成任务指令或阶段计划。 每个 GroupSession 只有一个调度循环。执行期间收到的用户补充立即保存,等本轮所有成员结束后,与整轮回复一起组成下一批 `pending_messages`。判断期间收到新输入会取消旧判断并重新调用;过期结果即使延迟返回也不能启动成员,其审计状态为 `superseded`,不会显示为失败。普通消息不打断已开始的成员执行,需要立即中断时使用停止操作。 恢复检查点保存未消费的 `pending_message_ids`,包括执行期间尚未交接的输入。重启后将这些消息交回同一个循环重新判断。 ```ts /** 通过 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: "研究方案并完成实现,避免重复已有成果。", 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: "分析当前方案并提出可执行的改进。" }); ``` `group.sessions.create({ workspace })` 创建独立的项目群聊。需要固定的 Group 自身会话时,使用 `group.sessions.self({ workspace })`:其 ID 恒等于 `group.id`,首次打开时创建,后续打开恢复同一会话。宿主负责提供稳定的 Workspace,恢复时必须使用相同的 Workspace。会话集合默认存储在内存中;跨进程恢复需要配置持久化存储 Provider。 Desktop 的 Chat 列表点击 Group 会打开固定自身会话,使用私有 `groups//main` Workspace;Work 中的项目群聊独立创建。自身会话标题使用 Group 名称,配置可从聊天页设置菜单打开,未读提示归属 Chat。 成员需要分别配置自己的执行模型与能力描述。决策调用一次提交所有成员的布尔问题,Group 将答案汇总为 `member_ids`。自定义 `dispatch_strategy.decide_dispatch()` 同样只返回 `{ member_ids: [...] }`,空数组表示本次不再回复。默认调度在用户新消息轮次允许成员回应问候、确认在线、点名和直接提问,并按每位成员的回复概率独立抽样:概率不超过 40% 时不回复,40%–75% 之间按 `(回复概率 - 0.4) / 0.35` 计算实际回复率,达到 75% 时一定回复。例如回复概率为 60%,实际回复率约为 57%;抽样仍可能选中零位或多位成员。决策答案必须提供 0–1 范围的 `probability`,它表示所选布尔结论的置信度;`value: false` 时用 `1 - probability` 还原回复概率,缺失或无效概率会使本次调度失败。成员回复触发的自动接力保留布尔判定,只在需要补充或继续工作时进行,避免重复确认。执行失败会记录到群聊;停止操作会取消正在进行的决策和成员执行,自动接力仍受轮次上限与重复内容检测约束。 每轮成员收到真实群成员名单、触发来源、最新用户请求和本轮新增消息。执行期间收到的用户补充在下一轮作为当前请求,不会被刚完成的成员回复覆盖。最新用户请求优先于群聊长期目标;问候、在线确认和角色询问只需简短回应,不授权自行创建文档或启动开发。自动接力默认结束,仅对用户已授权且明确未完成的工作、必要交接或成员问题继续;泛泛邀请、计划和等待用户提供需求不触发自我接力。 默认策略将每位成员的模型结论、置信度、回复概率、实际回复率、随机数(仅抽样时)和最终选择保存到调度记录的 `decision.member_decisions`。可据此区分模型判断与随机抽样导致的未回复。 Desktop 的模型设置展示“决策模型”,创建或编辑 Group 时在“调度模型”中选择 Jev。已有 Group 使用文本模型的配置需要重新选择决策模型。AI 辅助创建团队的草稿仍使用文本模型,与 Group 运行时调度分开。 ### 通过 HTTP 暴露 Agent Source: https://downcity.ai/zh/agent-sdk-docs/integration-patterns/expose-over-http/ 如果你的调用方和执行方不在同一个进程里,可以把本地 Agent 暴露成 HTTP 服务。 在 Downcity 里,HTTP 暴露由 Downcity 发布: ```bash downcity on ``` 然后别的进程用 City 的 Workspace 路径: ```ts new RemoteAgent({ url: "http://127.0.0.1:5314/agents/repo-helper/workspaces/project", }) ``` 去连接它。 这是一种很自然的本地 client-server 形态: - 本地 Agent 负责执行 - City 负责 HTTP 网关发布 - RemoteAgent 负责消费 如果你同时注册了 Powers,Downcity 也会把 Power 的 HTTP 路由一起挂出去。 ### SDK Surface Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/sdk-surface/ `@downcity/agent` 的根入口会导出完整的扩展类型,但普通应用不需要理解全部导出。先掌握日常核心,再按场景进入 Session、Power 或 transport 集成层。 ## 第一层:日常核心 大多数本地应用只需要: - `Workspace`:创建项目资源与安全边界,并接收 Shell。 - `Agent`:组合身份、模型、指令、Tools 与 Sessions。 - `City`:持有 Workspaces、Power 唯一实例、Agent/Group 集合与 transport。 - `agent.sessions.create({ workspace })` / `get()`:创建或恢复当前目录的 Session。 - `agent.session()`:打开 Agent 自己的那段对话。 - `session.prompt()`:启动一次 Turn。 - `turn.finished`:等待最终结果。 - `agent.dispose()`:释放 Agent 的 Sessions 并解除当前 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: "检查项目" }); const result = await turn.finished; } finally { await agent.dispose(); } ``` 本地 SDK 是 session-first 的:Agent 负责组合,真正承载交互和执行状态的是 Session。 ## 第二层:常用进阶 ### Agent 与 Workspace - `agent.set_instruction()`:更新 Agent 静态指令。 - `city.powers`:添加或移除 Power 实例并调用宿主管理 action。 - `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })`:在明确的 Agent/Workspace 执行作用域查询或调用 Power。 - `workspace.get_env()` / `set_env()` / `patch_env()`:管理项目执行环境。 - `agent.get_logger()`:读取 Agent 级日志;Shell 从当前选择的 Workspace 获取。 ### Session - `get_info()`:读取 Session 状态。 - `subscribe()`:订阅实时 Mutation。 - `messages()`:读取消息快照。 - `stop()`:停止当前执行。 - `system()`:读取当前 system。 - `interactions()` / `respond()`:处理审批、问题等用户异步交互。 - `fork()`:从现有 Session 创建分支。 - `snapshot()` / `syncshot()`:管理本地 Session system 快照。 - `set({ model })`:只覆盖当前本地 Session 的模型。 如果应用有实时 UI,应先读取初始 Session 与 Message 快照,再建立 `subscribe()`;重连后重新读取快照,不要把事件流当作完整数据库。 ## 第三层:框架集成 以下能力只在构建 Power、服务端或自定义运行时组件时使用: - `@downcity/type` 的 `CityRuntime`:自定义组合根嵌入 Agent 时使用的最小 City 跨包协议;具体 `City` 已经直接实现该协议。 - `PowerContext`:City 为当前 Agent/Workspace/Session/Turn 投影给 Power 的受限能力视图;宿主不应主动获取或持有它。 - `SessionTurnContext`:一个 Turn 中统一管理身份、生命周期、Step、输入与输出协作的执行上下文;Power 只接收其只读投影 `PowerExecutionContext`。 - `create_session_turn_context(...)`:自定义执行器或运行时组件创建上述 Turn Context 的标准工厂。 - `SessionExecutor` 与 `SessionExecutorPort`:模型请求与 Tool Loop 的默认执行器,以及 `SessionLoop` 接受注入的执行端口。 - `AgentSession` 等接口:替换或扩展默认实现。 - RPC/HTTP 类型:实现自定义 transport 或协议集成。 网络服务实现位于 `@downcity/agent`。本地 `Agent` 不监听端口,也不管理 transport 生命周期。 ## 本地与远程 | 能力 | 本地 `Agent` | `RemoteAgent` | | --- | --- | --- | | 创建、获取、列出 Session | 支持 | 支持 | | `prompt()`、订阅、消息、停止、审批、fork | 支持 | 支持 | | 注入 Workspace、Shell、Tools | 支持 | 不支持 | | 由 City 绑定 Powers | 支持 | 服务端负责 | | 设置 Agent 默认模型 | 支持 | 不支持 | | `session.set({ model })` | 支持 | 不支持 | | `session.set({ security })` / `session.status()` | 支持 | 支持 | | `snapshot()` / `syncshot()` | 支持 | 不支持 | | 管理服务端 transport | 不负责 | 只连接 | 远程 Session 的模型与 system 持久化策略始终由服务端宿主决定。 ## 生命周期规则 ```text 一个 Agent → 多个 `agent.sessions.create({ workspace })` Session → 一次 agent.dispose() ``` - Agent 不绑定单一 Workspace,同一个 Agent 可以进入多个 Workspace。 - `AgentSessions` 持有 Agent 的 Session;每个 Session 独立接收 Workspace 与 Power 上下文。 - `agent.dispose()` 会释放 Session 使用的资源并解除当前 City 绑定;Power 唯一实例和生命周期由 City 收口。 - `RemoteAgent.close()` 只关闭客户端连接,不释放服务端 Agent。 继续阅读:[Agent API](/zh/agent-sdk-docs/api-reference/agent-class)和[Session 总览](/zh/agent-sdk-docs/sessions/overview)。 ### Agent 类 Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/agent-class/ `Agent` 是本地 SDK 中可跨项目复用的执行主体,拥有稳定 ID、可见名称、能力描述、指令、模型、自定义 Tool 和 Session 集合。它不持有 Power,也不绑定 Workspace;`chat()` 暴露属于 Agent 自己的那段对话。 ## 公开 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()` Agent 自己就是一条 Session,用 `session()` 取用;所有会话(包括它)都从 `agent.sessions` 创建与恢复: ```ts const session = await agent.session({ workspace }); const turn = await session.prompt({ query: "这个仓库的结构怎么样" }); const work = await agent.sessions.create({ workspace }); ``` 会话用 `set` 写配置、`get` 读状态,两者共用同一个手柄: ```ts await session.set({ model, workspace }); // 写 session.get("workspace"); // 运行时实例 session.get("workspace_id"); // 持久化标识 ``` ## 原地重配 Agent `reconfigure` 在不替换实例的前提下更新身份与默认能力,省略的字段保持当前值: ```ts agent.reconfigure({ name: "写作助手", instruction: ["先列大纲,再写正文。"], model: next_model, }); ``` 它与重建 Agent 的区别是执行状态不会被清掉:实例、Session 集合与运行中的 Turn 都保留, 因此可以在会话还在跑的时候保存配置。变更对后续读取生效: | 字段 | 生效时机 | | --- | --- | | `name`、`description` | 立即,包括工具调用环境、Power 上下文、Group 提示词与 system 身份 | | `instruction` | 新建 Session 立即;已创建的 Session 需显式 `syncshot()` | | `model` | 下一个 Step;Session 级覆盖模型仍优先 | 执行中的 Turn 始终用完它开始时快照的 system 与模型,不会中途改口。已创建的 Session 在首次组装时 冻结 system,因此宿主若要让它看到新的指令,应对空闲会话调用 `session.syncshot()`; 名称与描述不进该快照,它们在每条调用环境里实时读取。 ## 模型侧的 Agent Power 加入 City 后,Session 可以使用 `agent` Power: - `agent.list`:列出当前 City 的 Agent,包含 `agent_id`、名称、职责简介、默认模型和是否为当前 Agent。 - `agent.create`:创建持久 Agent,入参为 `name`、`description`、`instruction`、`model_id`。执行前请求用户审批,成功后新 Agent 即可通过 `agent.list` 发现。 Desktop 和 CLI 宿主负责保存定义、装配模型并注册 Agent。自行装配 City 时,通过 `runtime.create_agent` 提供相同的创建能力;该回调应返回已持久化并加入当前 City 的 Agent,失败时由宿主回滚。 与目标 Agent 开始工作或继续交流,使用独立的 `session` Tool;详见[管理 Session](/zh/agent-sdk-docs/sessions/session-management)。 ## 构造参数 - `id`:稳定 Agent 身份和私有数据分区键。 - `name`:可选的用户可见名称;省略时使用 `id`。 - `description`:可选的一句话能力简介,供展示与 Group 语义调度使用。 - `tools`:该 Agent 创建的 Session 可使用的自定义 Tool。 - `callbacks`:请求完成处理函数注册表;`prompt({ request_id, query, callback: { name, data } })` 选择处理器,结束后可靠投递。详见[管理 Session](/zh/agent-sdk-docs/sessions/session-management)。 - `instruction`:Agent 级静态基础指令。 - `model`:默认 `ModelClient`,本地 Session 可以覆盖。 - `workspace`:可选的新建 Session 默认目录;不是绑定,可在会话中切换。 - `session_class`:可选的本地自定义 Session 类。 `workspace` 只是新建会话的默认目录。它不影响 Agent 的身份,也不会限制 Agent 之后使用其他目录; 省略时新建的会话没有项目上下文。每个 Session 都可以通过 `session.set({ workspace })` 或在创建时 传入 `{ workspace }` 选择自己的当前目录。详见 [Agent 对话](/zh/agent-sdk-docs/sessions/agent-chat) 与 [管理 Session](/zh/agent-sdk-docs/sessions/session-management)。 一个 Agent 可以在多个 Workspace 中工作。Power 由 City 提供,City 中全部 Power 自动对全部 Agent 可用;执行时从 `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id })` 创建明确的执行视图。`agent.dispose()` 会释放 Session 与当前 City 绑定;网络 transport 仍由宿主负责关闭。 ### RemoteAgent 类 Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/remote-agent-class/ `RemoteAgent` 的核心公开使用面包括: - `new RemoteAgent({ url })` - `agent.sessions` - `agent.run_power_action(input)` `agent.sessions.create()` 和 `agent.sessions.get()` 返回 `RemoteAgentSession`。 ## Sessions `agent.sessions` 返回一个 `AgentSessions`,包含以下方法: - `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` 当你的客户端 UI 需要调用远程 runtime 上的 Power Action 时,使用 `run_power_action()`。 ```ts await agent.run_power_action({ power: "example", action: "refresh", payload: { scope: "current-session", }, }); ``` Shell 审批属于具体远程 Session,不属于 `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" } }, }); ``` 也可以在统一 Mutation 流里处理 pending Interaction: ```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); }); ``` ## `url` 当前构造参数是: ```ts new RemoteAgent({ url: "http://127.0.0.1:5314", }); ``` 而不是 `baseUrl`。 `url` 当前支持: - `http://...` - `https://...` - `rpc://...` ## 核心语义 它本身不执行任务,只负责连接远程 session 能力。 如果你把本地 `Agent` 看成服务端执行壳,那 `RemoteAgent` 就是与之配对的客户端。 ## 一个重要限制 远程 Session 不提供模型配置方法;模型选择和实例注入由服务端宿主负责。 也就是说: ```ts await session.set({ model }); ``` 当前不支持。 模型应该由服务端本地 `Agent` 或服务端本地 session 持有。 ### Session 类型与结果 Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/session-types/ `SessionMessage`、`SessionMutation`、`SessionOrigin`、Interaction 与 JSON 值等跨 package 数据协议统一从 `@downcity/type` 导入;`AgentSession`、Turn Handle 等 Agent 可执行契约从 `@downcity/agent` 导入。 最常见的输入输出对象有: - `AgentSession` - `RemoteAgentSession` - `AgentSessionSetInput` - `AgentSessionSetOptions` - `RemoteSessionSetInput` - `AgentSessionStatus` - `AgentSessionPromptInput` - `AgentSessionStopResult` - `AgentSessionTurnHandle` - `AgentSessionTurnResult` - `SessionMutation` - `SessionMessage` - `SessionInteractionRequest` - `RespondSessionInteractionInput` - `AgentSessionInfo` ## 先按用途选择类型 | 你要做什么 | 应该消费的对象 | | --- | --- | | 首次加载、历史与重连恢复 | `SessionMessagePage` / `SessionMessage` | | 实时文本、工具和标题 | `SessionMutation` | | 等待一次执行结束 | `AgentSessionTurnHandle` / `AgentSessionTurnResult` | | 展示或提交用户异步交互 | `SessionInteractionRequest` / `SessionInteractionResult` | | 侧边栏与当前会话头部 | `AgentSessionSummary` / `AgentSessionInfo` | | 运行状态与安全策略 | `AgentSessionStatus` | `AgentSessionStatus.security` 同时提供 configured `approval_mode` 与检查点已经提交的 `effective_approval_mode`。两者不同时,配置仍在 Session Queue 中等待执行。 `AgentSessionSetOptions` 控制真实配置变化提交后的可观测输出:`persist_action` 决定是否写入包含 Action Part 的 Agent Message,`publish_mutation` 决定是否通过 Session 订阅发布对应 Message Mutation。两者均省略时保持默认的持久化与发布行为。 ## `AgentSessionPromptInput` 核心字段: - `query`:本次新增文本或内容 parts。 - `request_id`:可选的稳定标识,用于幂等提交。 - `visibility`:`"visible"`(默认)或 `"internal"`。内部输入仍持久化、进入模型上下文并触发执行,但不在普通历史和聊天界面展示;其模型输入角色与指令优先级不变。 委派结果使用 `visibility: "internal"`,使运行时通知不会展示成用户发言。消费消息订阅的宿主应在渲染前过滤内部消息。 ## `AgentSessionTurnHandle` 核心字段: - `id` - `result` - `finished` ## `AgentSessionTurnResult` 核心字段: - `turn_id` - `success` - `error` - `text` `text` 用于简洁的最终结果。结构化 Assistant Message 只有一份 canonical 状态, 应通过 `session.messages()` 与 `session.subscribe()` 读取,不会在 Turn Result 中复制。 ## `AgentSessionStopResult` 核心字段: - `stopped` - `turn_id` - `cancelled_queued_prompts` - `reason` `session.stop()` 会停止当前 turn,并取消尚未被当前 turn 吸收的排队 prompt。空闲时调用不会抛错,会返回 `stopped: false` 和 `reason: "idle"`。 ## 深入阅读 - [输入与 Turn](/zh/agent-sdk-docs/sessions/prompt-and-turns):输入格式、steering 与排队语义。 - [Message 与 Part](/zh/agent-sdk-docs/sessions/messages):持久化线性历史、revision 与分页。 - [实时订阅](/zh/agent-sdk-docs/sessions/subscribe):八类 Mutation 与客户端合并规则。 - [用户异步交互](/zh/agent-sdk-docs/sessions/approvals):Session 级审批、问题与响应。 - [Session 详情](/zh/agent-sdk-docs/sessions/info):摘要、标题与执行状态字段。 ### Session Mutation Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/session-events/ `session.subscribe()` 返回统一的 `SessionMutation`。Message 数据、流式增量、Turn 生命周期和 Session 状态都通过同一个订阅入口传递。 所有 Mutation 都有 `mutation_id`、`session_id` 与 `created_at`。Message 相关 Mutation 还包含 `message_id` 与 `revision`,客户端用它们定位目标消息并防止旧快照覆盖新状态。 ## 最常见的是文本 delta 如果你只是做实时文本输出,通常只需要先处理: ```ts if (mutation.variant === "delta" && mutation.type === "text") { process.stdout.write(mutation.delta); } ``` 七种 variant 的职责是: - `message`:完整顶层 Message 快照 - `part`:完整 Assistant Part 快照,例如 Tool 状态 - `delta`:text / reasoning / tool_input 新增片段 - `turn`:Turn 的 `start` / `finish` 生命周期 - `file_diff`:当前 Turn 的文件改动实时摘要 - `session`:Session 自身状态变化,例如 `title` - `warning`:不写入历史的运行期警告,例如模型请求失败与重试 Interaction 作为所属 Tool Part 的从属数据进入 `pending` 并携带完整请求;该 Tool 同时进入 `waiting-user`。订阅保持为纯数据通道,用户决定通过 Session 命令 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" } }, }); ``` Mutation 是实时协议,不是历史格式。加载已有历史使用 `messages()`;网络重连后重新读取 Message 与 Session 快照并重新订阅。完整的字段表、状态机和无丢事件同步方案见 [实时订阅](/zh/agent-sdk-docs/sessions/subscribe) 与 [重连与同步](/zh/agent-sdk-docs/sessions/reconnect-and-sync)。 ### SDK HTTP 端点 Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/sdk-http-endpoints/ 这组 SDK 路由不属于 `Agent` 自身。 它们由 `city.http()` 返回的 City HTTP transport 暴露。City 可以直接启动 HTTP server, 也可以将 router 挂载到 Downcity gateway 等宿主 server 上。 当前核心包括: - `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` ## 设计目标 这套接口不是一个通用“大而全 API”,而是一组尽量小、围绕 session 使用面展开的 SDK 路由。 它的重点不是扩展成复杂平台 API,而是保证本地 `Agent` 和 `RemoteAgent` 之间的能力面对齐。 ## 典型使用方式 ```bash downcity on ``` ### City RPC Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/agent-rpc/ RPC 属于 `City`,不属于单个 `Agent`。使用 `city.rpc()` 在一个 NDJSON-over-TCP 监听器中暴露当前 City 中的全部 Agent。 ```ts const rpc = await city.rpc({ host: "127.0.0.1", port: 15314 }); console.log(rpc.binding()?.url); await city.close(); ``` Remote 客户端使用 `rpc://host:port//`。完整示例参见 [City transport](/zh/city-sdk-docs/zh/packages/city/transport)。 ### City HTTP Source: https://downcity.ai/zh/agent-sdk-docs/api-reference/agent-http/ HTTP 属于 `City`,不属于单个 `Agent`。使用 `city.http()` 在一个监听器中暴露当前 City 中的全部 Agent。 ```ts const http = await city.http({ host: "127.0.0.1", port: 5314 }); console.log(http.binding()?.url); await city.close(); ``` 请求路径前缀为 `/agents//workspaces/`。完整示例参见 [City transport](/zh/city-sdk-docs/zh/packages/city/transport)。 ### 例子:最小本地 Agent Source: https://downcity.ai/zh/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", }); ``` 然后: ```ts const session = await agent.sessions.create({ workspace }); await session.set({ model }); const turn = await session.prompt({ query: "继续" }); await turn.finished; ``` 这条最小例子适合先验证 3 件事: - Agent 能不能成功构造 - Session 能不能成功创建 - 模型能不能成功执行 ### 例子:最小 RemoteAgent Source: https://downcity.ai/zh/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: "总结一下这个项目", }); const result = await turn.finished; ``` 如果你走本机 RPC,也可以写成: ```ts const agent = new RemoteAgent({ url: "rpc://127.0.0.1:15314/repo-helper/project", }); ``` 建议先用这个最小例子验证远程连通性,再逐步叠加更复杂的订阅式 UI 或多 session 行为。 ### 例子:实时 UI Source: https://downcity.ai/zh/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: "继续", }); await turn.finished; unsubscribe(); ``` 如果你还想显示工具过程,可以处理: - `variant: "part"` 的 Agent 文本、Tool、Interaction、Action 与 Error Part - `variant: "message"` 的完整 User 与 Agent Message 快照 - `variant: "turn"` 的开始与完成状态 - `variant: "session"` 的标题等 Session 状态 这个模式特别适合终端实时输出和网页实时聊天 UI。 ### SDK Session 布局 Source: https://downcity.ai/zh/agent-sdk-docs/storage/sdk-session-layout/ 本地 SDK Agent 的 Session 保存在 City 注入的存储根目录下;使用本地 Provider 时通常是 `~/.downcity`: ```text ~/.downcity/agents//sessions/// ├── session.db └── attachments/ ``` Session 以 `(origin.type, session_id)` 作为稳定身份,默认分区是 `chat`。其他非空来源类型会被 安全编码为单个目录段,例如 `automation/daily` 不会产生嵌套目录。项目目录不会创建 `.downcity`。 `session.db` 的核心表: - `session_state`:身份、Workspace 归属、标题、模型标签、时间戳与 system snapshot。 - `messages`:User/Agent Message envelope。 - `message_parts`:按 sequence 保存每个 Part 的具体内容。 Composer Policy 可以在同一数据库中维护自己的 `composer__*` 派生表。派生表可以删除重建, 不会改变 canonical Message。应用应调用 `session.messages()` 读取消息,不要直接写数据库。 完整设计见 [Session 元数据与落盘](/zh/agent-sdk-docs/sessions/metadata-and-storage)。 ### SDK FAQ Source: https://downcity.ai/zh/agent-sdk-docs/faq/sdk-faq/ ## 为什么本地 session 需要先 `set({ model })`? 因为本地 SDK Agent 负责的是执行壳和会话能力,具体模型实例由调用方显式传入。 ## 为什么 RemoteAgent 不能远程传本地模型实例? 因为模型实例通常不可序列化,应由服务端本地持有。 ## 为什么本地 SDK 和项目 runtime 的模型配置方式不一样? 因为它们面向的使用场景不同: - 项目 runtime 面向长期运行的项目执行体,模型来自 `execution.modelId` - 本地 SDK 面向嵌入式调用,模型实例由调用方显式注入 ### SDK Session 没有模型 Source: https://downcity.ai/zh/agent-sdk-docs/troubleshooting/sdk-session-has-no-model/ 这是本地 SDK 最常见的问题之一。 ## 原因 本地 SDK session 默认没有模型,除非宿主主动提供。 你可以用任一方式修复: ```ts const agent = new Agent({ id: "repo-helper", model, }); const workspace = new Workspace({ id: "project", path: "/path/to/project", }); ``` 或者: ```ts await session.set({ model }); ``` ## 正确顺序 ```ts const session = await agent.sessions.create({ workspace }); const turn = await session.prompt({ query: "继续" }); await turn.finished; ``` 如果你已经确认本地 `Agent` 创建成功,但执行仍然失败,这通常就是第一优先排查点。 ## 远程 session 呢 远程 session 当前不支持远程传本地模型实例。 ### 模型请求长时间没有进展 Source: https://downcity.ai/zh/agent-sdk-docs/troubleshooting/model-request-progress/ 工具显示执行中时,先检查 Agent 日志中的阶段记录。工具参数由模型流式生成,生成参数和实际执行工具是两个阶段。 ## 定位停滞阶段 Session 执行器记录以下边界: | 日志 | 含义 | | --- | --- | | `[agent] step.input_start` | 开始提交待处理输入并装配当前步骤 | | `[agent] step.input_ready` | 输入装配完成,包含模型 ID、上下文窗口和消息、工具数量 | | `[agent] model_request.request_start` | 开始一次模型调用 | | `[agent] model_request.stream_open` | 模型客户端返回事件流 | | `[agent] model_request.first_event` | 消费到第一个模型事件;可能只是 `model_start`,不代表已经生成正文 | | `[agent] model_request.waiting` | 调用尚未结束时,每 30 秒记录当前阶段和最近事件 | | `[agent] model_request.stream_end` | 模型流已关闭且协议校验通过;本步骤的工具仍可能正在执行 | | `[agent] model_request.request_failed` | 模型调用或事件处理失败,具体原因结合失败日志查看 | | `[agent] step.finish` | 当前模型步骤及其工具执行完成 | 用 `session_id` 和 `turn_id` 定位执行轮次,再用 `invocation_id` 区分每次模型调用;`attempt` 表示同一步骤中的尝试次数。 `waiting` 中的 `stage` 表示当前等待的操作:`opening` 为等待模型客户端返回流,`reading` 为等待下一个流事件,`accepting` 为处理事件及输出回调。`last_event_type`、`last_event_at` 和 `event_count` 可帮助判断是否持续收到事件。日志不包含消息正文或工具参数。 只有 `input_start` 而没有 `input_ready` 时,优先检查输入提交和上下文装配;已经 `stream_end` 却没有 `step.finish` 时,继续检查工具执行和步骤收尾。 ## 核查上下文窗口 查看 `input_ready` 或模型请求日志的 `context_window`。`null` 表示这次执行没有有效窗口值,无法据此计算上下文压力。此时应检查模型定义及其目录元数据的传递,不要根据模型名称猜测窗口大小。 窗口有效时,当前压力阈值为 95%。上下文较长但没有压缩记录,并不单独证明压缩异常。这些日志用于诊断,不会自动设置请求超时、重试已产生部分输出的请求,或恢复正在运行的会话。 更新运行时代码并重新构建、启动宿主后,新的模型调用才会产生这些记录;已有停滞请求不会补写历史阶段。 ## Payments ### Accounts, Credits, and Payment Source: https://downcity.ai/en/payments/ `@downcity/services` separates four domains: - `AccountsService` owns login and user identity. - `CreditsService` owns Primary / Ephemeral Cards and accounting records. - `PaymentService` owns payment orders, Checkout, and webhooks. - `UsageService` aggregates user Credits consumption and AI technical usage. ```ts const credits = new CreditsService(); const ai = new AIService({ credits }); const payment = new PaymentService({ providers: [stripePaymentProvider()], 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}`, }); }, }); federation.use(new AccountsService()); federation.use(credits); federation.use(ai); federation.use(payment); federation.use(new UsageService({ ai_usage_reader: ai, credits_usage_reader: credits, })); ``` Start with [Credits Cards](/en/payments/credits) for the credit model, then choose a payment provider. ### Registration and Login Source: https://downcity.ai/en/payments/accounts/register-and-login/ `AccountsService` gives a downcity the shared account infrastructure, but email registration and login are available only when an email accounts provider is mounted and configured. Before rendering an email form, ask the server which providers are enabled: ```ts const providers = await guest.service("accounts").get("providers"); ``` Render email registration or login only when the response contains an enabled `email` provider with `type: "input"`. The form fields can come directly from that provider's `inputs`. ## register ```ts const registration = await guest.service("accounts").action("register").invoke({ email: "user@example.com", password: "password123", }); ``` Complete email verification using the verification message before logging in. Registration does not deliver a User Token. ## login ```ts const start = 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: start.login_id, login_secret: start.login_secret, input: { email: "user@example.com", password: "password123", }, }); const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.login_secret, }); // Retain this ID and reuse it if the exchange request must be retried. 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: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## Why `bureau_id` is included here Login requests specify the Bureau context for the User Token that Federation issues after authorization. Registration creates the account and starts email verification. So it needs to know: - which downcity is currently being entered - which downcity context the token should be issued for ## What happens after login succeeds Do not keep using the guest client forever. Switch into a normal user-context client: ```ts const user = new City({ role: "user", city_url, user_token: session.user_token, }); ``` ### Login and Session Source: https://downcity.ai/en/payments/accounts/oauth-and-session/ The accounts service owns the shared account tables, better-auth runtime, OAuth callback, sessions, profiles, and City `user_token` issuance. Concrete login methods are provider-driven. ## How to think about login entry points Product login is a unified state machine: - `providers` - `login/start` - `login/continue` - `login/result` - `login/exchange` In a typical downcity flow, `providers` tells the frontend which login methods are actually enabled on the current City and whether each method uses the `oauth` or `input` flow. `login/start` creates a login attempt and returns the next step. `login/continue` submits input with the claim proof. `POST login/result` accepts `login_id` and `login_secret` and returns status only. `POST login/exchange` accepts that proof and a stable random `exchange_id` to deliver `user_token`. Keep the secret exclusively in the initiating client; send it in JSON bodies, never URLs, pages, or logs. `GET /v1/accounts/providers` is a client-facing enabled list. Providers that are mounted in code but missing required env or runtime configuration are not returned. Use the Federation env catalog or admin workspace to discover what still needs to be configured. The `oauth` flow returns an authorization URL from `login/start`. The `input` flow returns the current step `inputs` from `login/start`; email uses `email/password`. Local Account is also an input flow, but with an empty `inputs` array, and `login/start` immediately returns `status: "done"`. In every case, obtain the token through `login/exchange`. Reuse the original `exchange_id` on transport retries; a different ID cannot claim the same result again. `login/start` requires an explicit `client_type`. `web` requires same-origin requests and the initiating browser cookie. OAuth flows for `cli`, `desktop`, and `device` return a `user_code`: display it in the original client so users can enter it on the browser confirmation page. Provider authentication must be followed by explicit authorization confirmation before token exchange. The official accounts service can expose email, GitHub, Google, and WeChat Website App login depending on mounted providers and server-side configuration. The frontend should always trust `providers` instead of hardcoding login buttons. If you want to actually wire up Google login next, continue with [Google Login Setup](/en/payments/accounts/google-login). If you want to actually wire up WeChat website login next, continue with [WeChat Login Setup](/en/payments/accounts/wechat-login). ## `me` and `logout` These already belong to logged-in user context, not guest access. ```ts const me = await user.service("accounts").get("me"); await user.service("accounts").action("logout").invoke({}); ``` Logout revokes the current User Token. With the SDK, use `await embassy.user.account.logout()`; credentials are cleared only after revocation succeeds and retained on failure for retry. Bureau checks new protected requests online with Federation and rejects access when validation is unavailable. Streams already running are not forcibly interrupted. ## Why session still matters here Even if the downcity mainly uses `user_token` to call City later, the account system still needs: - better-auth auth tables - a session table - login-state facts - downcity-facing profile data That is why the service is not just "a few routes," but a minimal account infrastructure layer. Mounting `AccountsService` does not make every login method available. Email login requires an email provider with a verification-email sender and completed email verification. OAuth login requires the matching provider and client credentials. OAuth identities are identified by provider and provider user ID; matching email addresses never silently merge accounts. Email conflicts require a verified linking or recovery flow. ## What `me` returns now `GET /v1/accounts/me` no longer returns only the runtime `user`. It also returns: - `profile`: downcity-facing display data such as display name and avatar That keeps product clients and admin tools from reconstructing user state directly from auth rows. ### Google Login Setup Source: https://downcity.ai/en/payments/accounts/google-login/ This page is about the Google OAuth Web Application login flow. It maps to a Google Cloud web-application OAuth client, not Android, iOS, Chrome Extension, or desktop credentials. ## What you need first Before integration, you will usually need: - a Google Cloud project - a Google account that can manage that project - the public City domain you plan to use - if the app is public, a homepage, privacy policy, and support email If these prerequisites are not ready, client creation and later OAuth approval often get blocked on the Google side. ## Where to find the Google client ID and client secret In Google's current official console flow, the usual path is: 1. Open Google Cloud Console 2. Go to `Google Auth Platform` 3. If this is the first setup, complete `Branding` and `Audience` first 4. Open `Clients` 5. Click `Create Client` 6. Choose `Web application` 7. Save the generated `Client ID` and `Client secret` Many older tutorials still say `APIs & Services -> Credentials`. If your UI does not match older guides, follow the current `Google Auth Platform` flow instead. ## What to configure in Downcity The accounts service reads these two environment variables: - `GOOGLE_CLIENT_ID` - `GOOGLE_CLIENT_SECRET` That means: - Google `Client ID` -> `GOOGLE_CLIENT_ID` - Google `Client secret` -> `GOOGLE_CLIENT_SECRET` in Federation env. ## Which callback URL to register The OAuth callback URL used by the accounts service is fixed: ```txt https://your-base-domain/v1/accounts/oauth/callback ``` For example: ```txt https://downcity.example.com/v1/accounts/oauth/callback ``` Register that exact URL under the Google OAuth client's `Authorized redirect URIs`. If you are only using the Downcity accounts service server-side OAuth flow, this redirect URI is the critical part. You usually do not need to register your downcity frontend as a JavaScript origin for this flow alone. ## The fields Google users most often get wrong - choosing the wrong `Application type` instead of `Web application` - putting the downcity frontend URL into `Authorized redirect URIs` instead of the City callback URL - using one public domain in production but registering another one in Google Cloud - changing redirect settings and testing immediately before Google finishes applying the change ## How the frontend should use it Do not hardcode the Google login button. Ask the server which providers are actually enabled: ```ts const providers = await guest.service("accounts").get("providers"); ``` If the response contains: ```ts { id: "google", type: "oauth", enabled: true } ``` then render a Google login entry. Start login through the unified `login/start` entry: ```ts const start = await guest.service("accounts").action("login/start").invoke({ provider: "google", client_type: "cli", bureau_id: "demo", }); ``` For this CLI example, display `start.user_code` in the original client before opening `start.url`. Users enter that code on the browser authorization confirmation page after Google authentication. Keep `start.login_secret` private in the initiating client. Poll the following POST until `status.status === "done"`, then run the exchange once. Retain `exchange_id` for transport retries: ```ts const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.login_secret, }); const exchange_id = crypto.randomUUID(); if (status.status !== "done") throw new Error("Authorization is not ready"); const result = await guest.service("accounts").action("login/exchange").invoke({ login_id: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## Why you may see testing-mode or unverified prompts If your Google OAuth setup is still missing Branding, Audience, domain details, or other required information, or if the app is still in a limited testing state, Google may block or warn during sign-in. Downcity only handles the standard OAuth start and callback flow. It does not bypass Google's own branding, audience, or verification requirements. ## What this flow returns After Google login succeeds, the accounts service still returns a normal City `user_token`. So the downcity does not need a different post-login client flow just because the user came from Google: ```ts const user = new City({ role: "user", city_url: "https://base.example.com", user_token: result.user_token, }); ``` ## Common mistakes - assuming the frontend URL should be used as `redirect_uri` - storing only the `Client ID` and forgetting the `Client secret` - hardcoding the Google login button instead of checking `providers` - creating the wrong Google OAuth client type, which breaks the callback flow ## Related docs - For the overall OAuth flow, continue with [OAuth and Session](/en/payments/accounts/oauth-and-session) - If you also need WeChat Website App login, continue with [WeChat Login Setup](/en/payments/accounts/wechat-login) - For the full integration path, read [Accounts Service](/en/payments/accounts) - After deployment, write `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` into Federation env through the Admin API or the `fed` admin workspace ### WeChat Login Setup Source: https://downcity.ai/en/payments/accounts/wechat-login/ This page is specifically about the "WeChat Open Platform -> Website App -> WeChat Login" flow. It is not Official Account login, and it is not Mini Program login. The primary shape of this flow is PC-side QR-code login. However, according to the current WeChat documentation, newer Windows and Mac WeChat desktop clients may sometimes show a fast-login confirmation instead of requiring a QR scan every time when the user is already signed in locally. ## What you need first Before integration, make sure you already have: - a WeChat Open Platform developer account - a Website App that has passed review - WeChat Login enabled and approved for that Website App - the `AppID` and `AppSecret` for that Website App If these prerequisites are missing, adding a `wechat` provider in code will not make real login work. ## What to configure in Downcity The accounts service reads these two environment variables: - `WECHAT_CLIENT_ID` - `WECHAT_CLIENT_SECRET` That means: - WeChat Website App `AppID` -> `WECHAT_CLIENT_ID` - WeChat Website App `AppSecret` -> `WECHAT_CLIENT_SECRET` in Federation env. ## Which callback URL to register The OAuth callback URL used by the accounts service is fixed: ```txt https://your-base-domain/v1/accounts/oauth/callback ``` For example: ```txt https://downcity.example.com/v1/accounts/oauth/callback ``` The callback URL registered in the WeChat Open Platform Website App console must match this exact URL. If you use a custom domain, register the same path under that custom domain. ## Why the domain often fails When WeChat website login starts, the accounts service generates a login URL under: ```txt https://open.weixin.qq.com/connect/qrconnect ``` with the current City `redirect_uri`. If the callback domain configured in WeChat does not match the real public City domain used by the downcity, login usually fails immediately. So two things must stay aligned: - the real public City domain used by the downcity - the exact callback URL registered in WeChat ## How the frontend should use it Do not hardcode the WeChat login button. Ask the server which providers are actually enabled: ```ts const providers = await guest.service("accounts").get("providers"); ``` If the response contains: ```ts { id: "wechat", type: "oauth", enabled: true } ``` then render a WeChat login entry. Start login through the unified `login/start` entry: ```ts const start = await guest.service("accounts").action("login/start").invoke({ provider: "wechat", client_type: "cli", bureau_id: "demo", }); ``` For this CLI example, display `start.user_code` in the original client before opening `start.url`. Users enter that code on the browser authorization confirmation page after WeChat authentication. Keep `start.login_secret` private in the initiating client. Poll the following POST until `status.status === "done"`, then run the exchange once. Retain `exchange_id` for transport retries: ```ts const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.login_secret, }); const exchange_id = crypto.randomUUID(); if (status.status !== "done") throw new Error("Authorization is not ready"); const result = await guest.service("accounts").action("login/exchange").invoke({ login_id: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## What this flow returns After WeChat login succeeds, the accounts service still returns a normal City `user_token`. So the downcity does not need a special post-login client flow just because the user came from WeChat: ```ts const user = new City({ role: "user", city_url: "https://base.example.com", user_token: result.user_token, }); ``` ## Common mistakes - mixing Website App login with Official Account login - mixing Website App login with Mini Program login - configuring one domain in WeChat while the downcity actually uses another City domain - hardcoding the `wechat` button instead of checking `providers` first ## Related docs - For the overall OAuth flow, continue with [OAuth and Session](/en/payments/accounts/oauth-and-session) - For the full integration path, read [Accounts Service](/en/payments/accounts) - After deployment, write `WECHAT_CLIENT_ID` and `WECHAT_CLIENT_SECRET` into Federation env through the Admin API or the `fed` admin workspace ### Admin Operations Source: https://downcity.ai/en/payments/accounts/admin-operations/ The accounts service does not only expose registration and login to frontends. It also exposes admin-side services. ## Common admin reads ```ts const users = await admin.service("accounts").get("users"); const sessions = await admin.service("accounts").get("sessions"); ``` ## What this means It means the service is not just a frontend auth feature. It is: - a set of user-context entry points - a set of admin-side reads or maintenance routes - a consistent set of user and session facts underneath ## Why this is tied to `Embassy Admin` Because these actions should not be executed in the browser. They fit better in: - admin dashboards - internal operations tools - trusted backend scripts ### Primary and Ephemeral Cards Source: https://downcity.ai/en/payments/credits/cards/ Each user has one permanent Primary Card and at most 100 unexpired Ephemeral Cards with a positive balance. `expires_at` must be ISO 8601 and is stored as UTC. ```ts const card = await bureau.credits.cards.create_ephemeral({ user_id: "user_123", name: "7-day welcome credits", initial_credits: 1_000_000, expires_at: "2026-08-03T00:00:00.000Z", source: "welcome", ref: "campaign_v1", idempotency_key: "welcome:user_123", }); await bureau.credits.topup({ card: { kind: "ephemeral", card_id: card.card_id }, credits: 100_000, source: "creator_reward", ref: "content_789", idempotency_key: "creator_reward:content_789", }); ``` Card lists return active Ephemeral Cards by default. Pass `include_history: true` to include unexpired depleted Cards. Expired Cards are deleted on the next access, while their accounting history remains available through Transaction Entries. ### Transactions and charge order Source: https://downcity.ai/en/payments/credits/transactions/ Every Topup or Charge creates one `CreditsTransaction`. Each affected Card produces an immutable `CreditsTransactionEntry`; Entries are also the ledger. ```ts await bureau.credits.charge({ user_id: "user_123", credits: 250_000, source: "model_usage", ref: "request_456", idempotency_key: "model_usage:request_456", }); ``` Automatic charges consume active Ephemeral Cards by earliest expiration, then the Primary Card. A selected Card charge never spills into another Card. Insufficient credits return `402` without a partial charge. ### Payment crediting Source: https://downcity.ai/en/payments/credits/payment-integration/ Payment status `paid` and wallet fulfillment status are independent: paid does not mean Credits have arrived. Free-amount topups credit the Primary Card; card purchases use the entitlement snapshot and stable fulfillment time captured for the order. This example handles topups only. Card fulfillment should use the second callback argument, `fulfillment`, for entitlement and expiration. ```ts const credits = new CreditsService(); const payment = new PaymentService({ providers: [stripePaymentProvider()], resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000, }), on_paid: async (record, fulfillment) => { if (fulfillment.entitlement.kind !== "topup") { throw new Error("This integration supports topups only"); } 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}`, note: record.note, }); }, }); ``` Users create a Payment order directly; Credits does not store pending payment topups: ```ts await city.payment.method("stripe").invoke({ topup_amount_minor: 500, idempotency_key: "order_123", note: "Buy Credits", }); ``` The user submits the intended payment amount. `resolve_topup` calculates and snapshots Credits on Federation, so the client never controls the conversion rate. Payment confirmation must use trusted amount, currency, and external transaction facts from a verified event or Provider API and compare them with the local order snapshot. Request metadata is not proof of payment. Tax, discount, and currency conversion semantics must be explicit; missing or mismatched facts block fulfillment and require review. Every `paid` event requires trusted amount, currency, and an external transaction ID; missing or mismatched facts cause a 422 failure and enter `review`. Stripe projects subtotal minus discount and checks the total equation. Creem uses signed order amount_paid when present, otherwise amount. Waffo converts the signed decimal amount exactly using currency precision. Dodo currently requires one-time dynamic pricing in the same currency, no discounts/PPP, and tax-inclusive pricing, then checks signed total_amount. See the Provider pages for exact fields and limits. These rules do not promise automatic support for arbitrary tax, discount, or currency-conversion schemes. ## Use your account wallet Open [Account → Wallet](/account?view=wallet) for available Credits and individual Credits cards. Your unique Primary never expires and has a bottom-right top-up action. Limited-time cards show remaining Credits, expiry and status. Select a card for its ledger, **Buy limited-time cards** for plan cards, or **Orders** for payment and delivery details. An expired card shows its last recorded balance but contributes no available Credits. Ledger balances remain historical facts. Top-ups credit the permanent Primary. Limited-time cards are bought through external payment without spending existing Credits. Operators configure name, currency, minor-unit price, Credits, duration in seconds and sale status in Fedman's card-plan page. No configuration means no plans on sale; local Demo prices are never production products. Purchases submit only a server `plan_id`, `method_id` and stable `idempotency_key`. Later price, entitlement or sale-status changes do not change existing orders. Validity starts at the persisted first fulfillment time; retries retain that time. The success redirect only asks you to check the order and never grants Credits. Paid orders with failed delivery can retry fulfillment. This wallet does not provide automatic refunds, chargeback recovery, balance-to-card exchange or subscriptions. Disabling a plan does not refund an order; contact the service operator for payment disputes. ## Event inbox and recovery After signature verification and normalization, webhook handling persists the trusted event in the inbox and enqueues it before returning HTTP 200. This acknowledges receipt, not completed fulfillment. Enqueue failures return an error, while the stored event remains recoverable. Queue runs `payment.events/process`; replay uses the stored normalized event without re-verifying the old HTTP request signature. The host must periodically call `payment.recover_events()`, or an administrator can call `POST /v1/payment/events/recover`, to wake due pending events and processing events with expired leases. The current Edge Worker scans every five minutes; `federation.json` declares deployment schedules through `deployment.crons`. Transient failures retry with exponential backoff starting at 15 seconds, capped at one hour, and move to `review` after 20 attempts. Amount validation failures (422) enter `review` directly instead of retrying indefinitely. Administrators use `GET /v1/payment/events/inbox` to inspect the latest 100 events, status, attempts, and diagnostic errors. After addressing the cause, call `POST /v1/payment/events/replay` with JSON `{ "event_id": "provider:event_id" }`. Replay repeats order validation and idempotent fulfillment; it never bypasses amount checks. Implement `on_paid(record, fulfillment)` idempotently using the stable `payment_id` and entitlement snapshot. ### Usage Facts and Metering Model Source: https://downcity.ai/en/payments/usage/event-model/ The usage view combines two independent fact sources: - `AIService` stores final technical metering for an AI execution, including tokens, image count, video seconds, and audio seconds. - `CreditsService` stores applied Credits charges. A stable `usage_id` links the facts, but neither is derived from the other. An execution may have technical usage without a charge, and token counts cannot be used to reconstruct Credits. ## Daily AI fields - `execution_count`: AI executions, including failed or cancelled executions without final metering. - `metered_request_count`: requests with trusted final metering. - `input_tokens`, `output_tokens`, `reasoning_tokens`, and `total_tokens`: token details returned by the AI service. - `image_count`, `video_seconds`, and `audio_seconds`: media usage. A stream is finalized only after it succeeds, fails, or is cancelled. If final metering is unavailable, the execution is still counted without inventing token or media values. ## Daily Credits fields The query includes only accounting records where `kind = charge`, `status = applied`, and Credits are positive: - `used`: Credits consumed. - `charge_count`: applied charge count. Top-ups, refunds, pending transactions, and failed transactions do not appear in the consumption heatmap. ### Querying and Summary Source: https://downcity.ai/en/payments/usage/querying-and-summary/ Usage exposes one user endpoint: `GET /v1/usage/me`. Identity comes from the `user_token`; callers cannot select another user with a parameter. ```ts const usage = await city.service("usage").get("me", { from: "2026-03-01", to: "2026-03-31", timezone: "America/Los_Angeles", }); ``` Days are grouped as local calendar days in the supplied time zone, including daylight-saving transitions. Dates must be real `YYYY-MM-DD` values in ascending order, and the range cannot exceed 400 days. ## Response shape ```ts { timezone: "America/Los_Angeles", from: "2026-03-01", to: "2026-03-31", credits_per_usd: 1_000_000, data_available_from: { credits: "2026-03-02", ai: "2026-03-02" }, summary: { credits: { used: 2500, charge_count: 3 }, ai: { execution_count: 3, metered_request_count: 3, total_tokens: 12400 } }, days: [/* dates with Credits or AI activity only */] } ``` Use `days[].credits.used` for product heatmaps and trends. Token and media fields explain actual AI consumption; they are not converted into Credits, and Credits cannot be used to infer them. Each `data_available_from` value is the earliest local date available from that fact owner, or `null` when no data exists. ### Usage and Credits Billing Source: https://downcity.ai/en/payments/usage/billing-integration/ A model or channel `bill` creates a Credits bill from the final output. `AIService` persists AI Usage with one `usage_id` and calls `CreditsService` with `ai:${usage_id}` as the idempotency key. ```ts const credits_service = new CreditsService(); const ai_service = new AIService({ credits: credits_service }); ai_service.use({ id: "example-model", channel_id: "example-provider", name: "Example Model", bill: ({ output }) => ({ credits: calculate_credits(output), note: "AI model usage", }), runtime: provider_runtime, }); ``` Insufficient balance rejects settlement. Database or Credits infrastructure errors become retryable. Retries neither duplicate AI Usage nor double-charge Credits. Usage queries include only `applied` Credits charges, so accounting trends always follow the Credits ledger. ### Stripe Payment Provider Source: https://downcity.ai/en/payments/payment-stripe/ ```ts const payment = new PaymentService({ providers: [stripePaymentProvider()], resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000 }), on_paid: async (record, fulfillment) => { if (fulfillment.entitlement.kind !== "topup") { throw new Error("This integration supports topups only"); } 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}`, }); }, }); ``` Configure both `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`. Without webhook verification configuration the Provider is disabled and webhook requests are rejected. Send webhooks to `/v1/payment/webhook?provider=stripe`. The example handles free-amount topups only. Paid status and wallet fulfillment are independent; `on_paid(record, fulfillment)` uses the order's entitlement snapshot. Verified events are persisted and enqueued before acknowledgment. For signed Checkout events, the credited principal is `amount_subtotal - total_details.amount_discount`. All amount components must be nonnegative safe integers, and `amount_total` must equal principal plus `amount_tax` and `amount_shipping`; separate tax and shipping never buy Credits. Currency and principal must match the local order, and an external transaction ID is required. Missing components, mismatches, or tax-inclusive/mixed pricing that cannot satisfy this projection cause a 422 failure and move the event to `review`. PaymentIntent events without the required principal breakdown cannot independently authorize fulfillment. Request metadata is not proof of payment, and no automatic currency conversion is performed. See [Payment crediting](/en/payments/credits/payment-integration) for recovery, diagnostics, and administrator replay. ```ts await city.payment.method("stripe").invoke({ topup_amount_minor: 500, idempotency_key: "order_123", }); ``` ### Creem Payment Provider Source: https://downcity.ai/en/payments/payment-creem/ Add `creemPaymentProvider()` to `PaymentService.providers`. `CREEM_API_KEY`, `CREEM_PRODUCT_ID`, and `CREEM_WEBHOOK_SECRET` are all required. `CREEM_PRODUCT_ID` must reference a one-time product with tax included in its price, and `CREEM_CURRENCY` must match that product's currency. Payment passes the server-side order amount as Creem `custom_price`, which must be between 100 and 99,999,999 minor currency units. ```ts await city.payment.method("creem").invoke({ topup_amount_minor: 500, idempotency_key: "order_123" }); ``` Use `on_paid(record, fulfillment)` to fulfill the order snapshot; free-amount topups call `credits.topup()` idempotently with `payment:${record.payment_id}`. Paid status is independent from wallet fulfillment. Confirmation takes `order.amount_paid` from the signed event when present, otherwise `order.amount`, and uses `order.currency`. The amount must be a nonnegative safe integer in minor units; amount and currency must match the local order, and an external transaction ID is required. Missing, invalid, or mismatched facts cause a 422 failure and move the event to `review`. Request metadata is not proof of payment. Arbitrary tax, discount, or currency-conversion schemes are not automatically reconciled. Verified events are persisted and queued before acknowledgment. See [Payment crediting](/en/payments/credits/payment-integration) for recovery and administrator replay. ### Dodo Payment Provider Source: https://downcity.ai/en/payments/payment-dodo/ Add `dodoPaymentProvider()` to `PaymentService.providers` and configure the Dodo API key, product ID, and webhook key. ```ts await city.payment.method("dodo").invoke({ topup_amount_minor: 500, idempotency_key: "order_123" }); ``` Before creating checkout, the Provider fetches the configured product and requires a one-time `pay_what_you_want` price in the order currency, with no discount or purchasing power parity (PPP), and `tax_inclusive: true`. The product minimum price must not exceed the order amount. An incompatible product is rejected before checkout creation. Confirmation reads `total_amount` and `currency` from the signed payment event and requires an external transaction ID. The amount and currency must match the local order. This implementation strictly requires tax-inclusive Dodo pricing; it does not automatically support arbitrary tax, discount, or currency-conversion schemes. Missing, invalid, or mismatched confirmation facts produce a 422 validation failure and move the inbox event to `review` without issuing Credits. PaymentService stores orders and events; Credits receives confirmed credits only from `on_paid`. ## Webhooks Set the webhook URL in the Dodo Dashboard to `/v1/payment/webhook?provider=dodo` and subscribe to at least these payment lifecycle events: - `payment.processing` - `payment.succeeded` - `payment.failed` - `payment.cancelled` Dodo follows Standard Webhooks. The provider uses the official SDK and the exact raw request body to verify `webhook-id`, `webhook-timestamp`, and `webhook-signature`, and uses the unique `webhook-id` strictly for idempotency. Requests with a missing protocol header or an invalid signature are rejected. One payment can emit several lifecycle events and retries may arrive out of order. Verified events are persisted in the inbox and enqueued; HTTP 200 acknowledges receipt, not fulfillment. `payment.succeeded` is a payment confirmation signal; fulfillment must validate trusted payment facts against the order, not request metadata. Paid status and wallet fulfillment are independent, and `on_paid(record, fulfillment)` must be idempotent using `payment_id`. See [Payment crediting](/en/payments/credits/payment-integration) for amount-validation requirements, recovery scans, diagnostics, and administrator replay. ### Waffo Payment Provider Source: https://downcity.ai/en/payments/payment-waffo/ Add `waffoPaymentProvider()` to `PaymentService.providers` and configure the merchant ID, private key, product ID, and webhook public key. ```ts await city.payment.method("waffo").invoke({ topup_amount_minor: 500, idempotency_key: "order_123" }); ``` Use `on_paid(record, fulfillment)` for idempotent fulfillment from the order snapshot; free-amount topups credit the Primary Card. Paid status and wallet fulfillment are independent. Confirmation reads signed `orderAmount` and `orderCurrency`. The decimal amount string is converted exactly to integer minor units using the currency's precision, without floating-point rounding; malformed amounts, nonzero excess decimal places, and unsafe integers are rejected. Amount and currency must match the local order, and an external transaction ID is required. Missing, invalid, or mismatched facts cause a 422 failure and move the event to `review`. Request metadata is not proof of payment, and arbitrary tax or currency conversion is not automatically reconciled. Verified events are persisted and queued before acknowledgment. See [Payment crediting](/en/payments/credits/payment-integration) for recovery and administrator replay. ### Accounts、Credits 与 Payment Source: https://downcity.ai/zh/payments/ `@downcity/services` 提供四个职责独立的领域: - `AccountsService` 拥有登录与用户身份。 - `CreditsService` 拥有 Primary / Ephemeral Cards 和账务记录。 - `PaymentService` 拥有支付订单、Checkout 与 webhook。 - `UsageService` 聚合用户的 Credits 消费与 AI 技术用量。 ```ts const credits = new CreditsService(); const ai = new AIService({ credits }); const payment = new PaymentService({ providers: [stripePaymentProvider()], 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}`, }); }, }); federation.use(new AccountsService()); federation.use(credits); federation.use(ai); federation.use(payment); federation.use(new UsageService({ ai_usage_reader: ai, credits_usage_reader: credits, })); ``` 从 [Credits Cards](/zh/payments/credits) 开始了解额度模型,支付接入见各 Provider 页面。 ### 注册与登录 Source: https://downcity.ai/zh/payments/accounts/register-and-login/ `@downcity/services` 最直接解决的问题,就是让产品在没有自建账号系统的情况下先跑通注册登录。 渲染邮箱表单前,先读取服务端真实开放的登录方式: ```ts const providers = await guest.service("accounts").get("providers"); ``` 只有返回里包含启用的 `email` provider,并且它的 `type` 是 `input` 时,才展示邮箱输入表单。表单字段可以直接来自 provider 的 `inputs`。 ## register ```ts const registration = await guest.service("accounts").action("register").invoke({ email: "user@example.com", password: "password123", }); ``` 注册后先通过验证邮件完成邮箱验证,再登录。注册不直接交付 User Token。 ## login ```ts const start = 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: start.login_id, login_secret: start.login_secret, input: { email: "user@example.com", password: "password123", }, }); const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.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: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## 为什么这里要带 `bureau_id` 登录请求用它指定授权完成后由 Federation 签发 User Token 的 Bureau 上下文。注册负责创建账号并启动邮箱验证。 所以它需要知道: - 当前是哪个产品 - 当前要为哪个产品上下文请求签发 token ## 登录成功后要做什么 不是继续一直用 guest client,而是切到正常用户态: ```ts const user = new City({ role: "user", city_url, user_token: session.user_token, }); ``` ### 登录与 Session Source: https://downcity.ai/zh/payments/accounts/oauth-and-session/ accounts service 承载账号表、better-auth runtime、OAuth callback、会话、profile 和 City `user_token` 签发。具体登录方式由 provider 决定。 ## 登录入口怎么理解 产品侧登录是一个统一状态机: - `providers` - `login/start` - `login/continue` - `login/result` - `login/exchange` 常见产品接法里,`providers` 先负责告诉前端“当前 City 真的开放了哪些登录方式,以及它属于 `oauth` 还是 `input` 流程”;`login/start` 创建一次登录流程并返回下一步;`login/continue` 携带领取凭据提交 input;`POST login/result` 携带 `login_id`、`login_secret` 只查询状态;`POST login/exchange` 再携带稳定随机 `exchange_id` 领取 `user_token`。领取凭据仅由发起端保管,通过 JSON 请求体提交,不进入 URL、页面或日志。 `GET /v1/accounts/providers` 是面向客户端的已启用列表。provider 虽然已经在代码里挂载,但如果缺少 required env 或 runtime 配置,就不会出现在这里。配置目录和缺失项仍然通过 Federation env catalog 或 admin workspace 查看。 `oauth` 流程会在 `login/start` 返回授权 URL;`input` 流程会在 `login/start` 返回当前步骤的 `inputs`,例如 email 需要 `email/password`。Local Account 也是 input 流程,只是 `inputs` 为空,并且 `login/start` 会直接返回 `status: "done"`。无论哪一种,最终都通过 `login/exchange` 领取 Token。同一交换的网络重试必须复用原 `exchange_id`;不同 ID 重复领取会被拒绝。 `login/start` 必须显式传入 `client_type`:`web` 要求同源请求与发起浏览器 Cookie;`cli`、`desktop`、`device` OAuth 流程返回 `user_code`,须在原始客户端展示,由用户输入浏览器授权确认页。完成第三方认证后仍须确认授权,才可交换 Token。 当前官方 accounts service 可以按服务端配置开放邮箱、GitHub、Google、WeChat 网站应用登录。前端应该始终以 `providers` 返回为准,不要把登录按钮写死。 如果你现在要真正把 Google 登录配起来,继续读 [Google 登录配置](/zh/payments/accounts/google-login)。 如果你现在要真正把微信网站登录配起来,继续读 [微信登录配置](/zh/payments/accounts/wechat-login)。 ## `me` 和 `logout` 这两个动作已经属于用户态,不再是 guest 入口。 ```ts const me = await user.service("accounts").get("me"); await user.service("accounts").action("logout").invoke({}); ``` 退出撤销当前 User Token。SDK 使用 `await embassy.user.account.logout()`,撤销成功后才清理本地凭证;失败保留凭证以便重试。Bureau 对新的受保护请求向 Federation 在线检查 Token,检查不可用时拒绝访问。已开始的流式请求不强制中断。 ## session 在这里的意义 即使产品最终主要靠 `user_token` 调 City,账号系统仍然需要: - better-auth 认证事实表 - session 表 - 登录态相关事实 - 产品层 profile 资料 这也是为什么 service 不只是“几个路由”,而是一套完整账号基础设施的最小实现。 挂载 `AccountsService` 不等于所有登录方式都可用。邮箱登录需要 email provider 和验证邮件发送能力,完成邮箱验证后才能登录;OAuth 登录需要对应 OAuth provider 和 client credentials。OAuth 身份按 provider 与 provider 用户 ID 识别,不按相同邮箱静默合并账号;邮箱冲突需要经过验证的关联或恢复流程。 ## `me` 现在返回什么 `GET /v1/accounts/me` 不再只返回运行时 `user`,还会一起返回: - `profile`:产品层展示资料,例如展示名、头像、简介 这样产品前端和管理侧都不需要再自己去拼认证表。 ### Google 登录配置 Source: https://downcity.ai/zh/payments/accounts/google-login/ 这页讲的是 Google OAuth 的 Web Application 登录接法。 它对应的是 Google Cloud 里的网页应用 OAuth client,不是 Android、iOS、Chrome Extension 或桌面应用 client。 ## 先准备什么 在接入前,你通常需要先有: - 一个可用的 Google Cloud Project - 能管理这个项目的 Google 账号 - 准备对外使用的 City 域名 - 如果你面向公开用户开放登录,最好同时准备好产品主页、隐私政策和支持邮箱 如果这些前置条件没准备好,Client 创建过程和后续 OAuth 审核通常都会卡住。 ## 去哪里找 Google 的 Client ID 和 Client Secret 按 Google 当前官方控制台路径,一般这样操作: 1. 打开 Google Cloud Console 2. 进入 `Google Auth Platform` 3. 如果这是第一次配置,先完成 `Branding` 和 `Audience` 4. 进入 `Clients` 5. 点击 `Create Client` 6. 选择 `Web application` 7. 创建后保存生成的 `Client ID` 和 `Client secret` 很多旧教程会写成 `APIs & Services -> Credentials`。如果你看到的界面和旧教程不一样,以当前 `Google Auth Platform` 为准。 ## 在 Downcity 里配什么 accounts service 读取下面两个环境变量: - `GOOGLE_CLIENT_ID` - `GOOGLE_CLIENT_SECRET` 也就是把 Google OAuth client 里的: - `Client ID` -> `GOOGLE_CLIENT_ID` - `Client secret` -> `GOOGLE_CLIENT_SECRET` 写进 Federation env。 ## 回调地址怎么填 accounts service 的 OAuth 回调地址固定是: ```txt https://你的-base-域名/v1/accounts/oauth/callback ``` 例如: ```txt https://downcity.example.com/v1/accounts/oauth/callback ``` 这个地址要登记到 Google OAuth client 的 `Authorized redirect URIs`。 如果你只是在用 Downcity accounts service 的服务端 OAuth 流程,最关键的是这个回调地址;通常不需要为了这条链路额外去登记产品前端的 JavaScript origin。 ## Google 控制台里最容易填错的地方 - `Application type` 选错了,没有选 `Web application` - `Authorized redirect URI` 填成了产品前端地址,而不是 City 的 `/v1/accounts/oauth/callback` - 真实对外访问域名和 Google 后台登记域名不一致 - 改了回调地址后立刻测试,Google 配置还没有完全生效 ## 前端怎么接 前端不要把 Google 登录按钮写死,先读服务端真实开放的 provider: ```ts const providers = await guest.service("accounts").get("providers"); ``` 如果返回里有: ```ts { id: "google", type: "oauth", enabled: true } ``` 再展示“Google 登录”入口。 发起登录时统一调用 `login/start`: ```ts const start = await guest.service("accounts").action("login/start").invoke({ provider: "google", client_type: "cli", bureau_id: "demo", }); ``` 本例为 CLI 流程:先在原始客户端展示 `start.user_code`,再打开 `start.url`。用户完成 Google 认证后,将原始客户端短码输入浏览器授权确认页。`start.login_secret` 仅由发起端保管。轮询下面的 POST,直到 `status.status === "done"` 后再执行交换;保存 `exchange_id` 供网络失败时复用重试: ```ts const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.login_secret, }); const exchange_id = crypto.randomUUID(); if (status.status !== "done") throw new Error("授权尚未完成"); const result = await guest.service("accounts").action("login/exchange").invoke({ login_id: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## 为什么会看到测试模式或未验证提示 如果你的 Google OAuth 配置还没有把 Branding、Audience、域名和相关信息补完整,或者你的 app 仍处在受限测试状态,Google 侧可能会额外拦截或提示。 Downcity 这里只负责标准 OAuth 发起和回调,不会绕过 Google 自己的品牌、受众和校验要求。 ## 这条链路最终会返回什么 Google 登录完成后,accounts service 仍然会按统一方式返回 City `user_token`。 也就是说,产品侧不需要因为换成 Google 登录,就改掉后面的用户态调用方式: ```ts const user = new City({ role: "user", city_url: "https://base.example.com", user_token: result.user_token, }); ``` ## 常见误区 - 以为要把前端地址填进 `redirect_uri` - 以为只要有 `Client ID` 就够了,忘了同时写 `Client secret` - 直接把 Google 登录按钮写死,没有先读 `providers` - Google Cloud 里建成了别的 client 类型,导致整个回调链路不匹配 ## 相关文档 - 想看 OAuth 整体链路,继续读 [OAuth 与 Session](/zh/payments/accounts/oauth-and-session) - 如果你还要接微信网站登录,继续读 [微信登录配置](/zh/payments/accounts/wechat-login) - 想看完整接入流程,读 [Accounts 服务](/zh/payments/accounts) - 部署后记得通过 Admin API 或 `fed` 的 admin workspace 把 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET` 写进 Federation env ### 微信登录配置 Source: https://downcity.ai/zh/payments/accounts/wechat-login/ 这页讲的是“微信开放平台 -> 网站应用 -> 微信登录”这条链路。 它不是公众号登录,也不是小程序登录。 这条链路的主形态是 PC 端二维码登录。 不过按微信当前官方说明,在较新的 Windows / Mac 微信客户端里,如果用户电脑上已经登录了微信,也可能直接出现快速登录确认,而不是每次都扫码。 ## 先准备什么 在接入前,你需要先有: - 微信开放平台开发者账号 - 一个已审核通过的网站应用 - 该网站应用已开通并审核通过微信登录能力 - 该网站应用对应的 `AppID` 和 `AppSecret` 如果这些前置条件还没准备好,代码里即使加了 `wechat` provider,也无法真正完成登录。 ## 在 Downcity 里配什么 accounts service 读取下面两个环境变量: - `WECHAT_CLIENT_ID` - `WECHAT_CLIENT_SECRET` 也就是把微信开放平台网站应用里的: - `AppID` -> `WECHAT_CLIENT_ID` - `AppSecret` -> `WECHAT_CLIENT_SECRET` 写进 Federation env。 ## 回调地址怎么填 accounts service 的 OAuth 回调地址固定是: ```txt https://你的-base-域名/v1/accounts/oauth/callback ``` 例如: ```txt https://downcity.example.com/v1/accounts/oauth/callback ``` 微信开放平台网站应用后台里登记的回调地址,必须和这里完全一致。 如果你使用的是自定义域名,就填自定义域名下的同一路径。 ## 为什么域名经常出错 微信网站应用登录发起时,accounts service 会生成: ```txt https://open.weixin.qq.com/connect/qrconnect ``` 并带上当前 City 的 `redirect_uri`。 如果微信后台配置的回调域名和你当前真实对外访问的 City 域名不一致,用户在登录时通常会直接失败。 所以这里要保证两件事同时成立: - 产品真正访问的 City 公网域名是对的 - 微信后台登记的回调地址和这个域名完全一致 ## 前端怎么接 前端不要把微信登录按钮写死,先读服务端真实开放的 provider: ```ts const providers = await guest.service("accounts").get("providers"); ``` 如果返回里有: ```ts { id: "wechat", type: "oauth", enabled: true } ``` 再展示“微信登录”入口。 发起登录时统一调用 `login/start`: ```ts const start = await guest.service("accounts").action("login/start").invoke({ provider: "wechat", client_type: "cli", bureau_id: "demo", }); ``` 本例为 CLI 流程:先在原始客户端展示 `start.user_code`,再打开 `start.url`。用户完成微信认证后,将原始客户端短码输入浏览器授权确认页。`start.login_secret` 仅由发起端保管。轮询下面的 POST,直到 `status.status === "done"` 后再执行交换;保存 `exchange_id` 供网络失败时复用重试: ```ts const status = await guest.service("accounts").action("login/result").invoke({ login_id: start.login_id, login_secret: start.login_secret, }); const exchange_id = crypto.randomUUID(); if (status.status !== "done") throw new Error("授权尚未完成"); const result = await guest.service("accounts").action("login/exchange").invoke({ login_id: start.login_id, login_secret: start.login_secret, exchange_id, }); ``` ## 这条链路最终会返回什么 当微信登录完成后,accounts service 仍然会按统一方式返回 City `user_token`。 也就是说,产品侧不需要因为换成微信登录,就改掉后面的用户态调用方式: ```ts const user = new City({ role: "user", city_url: "https://base.example.com", user_token: result.user_token, }); ``` ## 常见误区 - 把网站应用登录和公众号登录混在一起 - 把网站应用登录和小程序登录混在一起 - 微信后台配置了别的域名,但产品实际访问的是另一个 City 域名 - 前端把 `wechat` 按钮写死,没有先读 `providers` ## 相关文档 - 想看 OAuth 整体链路,继续读 [OAuth 与 Session](/zh/payments/accounts/oauth-and-session) - 想看完整接入流程,读 [Accounts 服务](/zh/payments/accounts) - 部署后记得通过 Admin API 或 `fed` 的 admin workspace 把 `WECHAT_CLIENT_ID` 和 `WECHAT_CLIENT_SECRET` 写进 Federation env ### 管理侧操作 Source: https://downcity.ai/zh/payments/accounts/admin-operations/ accounts service 不只给前端提供注册登录入口,也会暴露管理侧 service。 ## 常见管理读取 ```ts const users = await admin.service("accounts").get("users"); const sessions = await admin.service("accounts").get("sessions"); ``` ## 这说明什么 说明 service 最终不是“前端专用功能”,而是: - 一组用户态入口 - 一组管理侧读取或维护入口 - 一套底层用户 / session 数据事实 ## 为什么这和 `Embassy Admin` 有关系 因为这类动作不该在浏览器里做。 它们更适合: - 后台管理系统 - 内部运营工具 - 安全的后端脚本 ### Primary 与 Ephemeral Cards Source: https://downcity.ai/zh/payments/credits/cards/ 每个用户只有一张永久 `Primary Card`。一个用户最多有 100 张未过期且有余额的 `Ephemeral Card`,适合新用户体验、活动或创作者奖励。`expires_at` 必须是 ISO 8601 时间,并会统一保存为 UTC。 ```ts const card = await bureau.credits.cards.create_ephemeral({ user_id: "user_123", name: "新用户 7 天体验卡", initial_credits: 1_000_000, expires_at: "2026-08-03T00:00:00.000Z", source: "welcome", ref: "campaign_v1", idempotency_key: "welcome:user_123", }); ``` 给现有 Card 增加额度: ```ts await bureau.credits.topup({ card: { kind: "ephemeral", card_id: card.card_id }, credits: 100_000, source: "creator_reward", ref: "content_789", idempotency_key: "creator_reward:content_789", }); ``` 默认 Card 列表只返回未过期且有余额的 Ephemeral Cards;传入 `include_history: true` 可查看尚未过期但已经耗尽的 Card。过期 Card 会在下一次访问时自动删除,历史账务仍保留在 Transaction Entries 中。 ### Transactions 与消费顺序 Source: https://downcity.ai/zh/payments/credits/transactions/ Topup 和 Charge 都产生一条 `CreditsTransaction`。每张受影响 Card 对应一条不可变 `CreditsTransactionEntry`,Entry 同时承担账本职责。 ```ts await bureau.credits.charge({ user_id: "user_123", credits: 250_000, source: "model_usage", ref: "request_456", idempotency_key: "model_usage:request_456", }); ``` 未指定 Card 时,服务按到期时间从早到晚消费有效 Ephemeral Cards,再消费 Primary Card。指定 Card 时不会跨 Card。总额度不足时整体失败并返回 `402`,不会产生部分扣费。 ```ts const transactions = await bureau.credits.transactions.list({ user_id: "user_123" }); const entries = await bureau.credits.history.list({ transaction_id: transactions[0].transaction_id }); ``` ### Payment 入账 Source: https://downcity.ai/zh/payments/credits/payment-integration/ `PaymentService` 管理 pending、paid、canceled 等订单状态。付款确认 `paid` 与 wallet 权益发放状态独立;`paid` 不等于 Credits 已到账。自由充值发放 Primary Card,套餐购买沿用下单时冻结的权益快照及稳定履约时间。下面示例只处理自由充值,套餐应使用回调第二参数 `fulfillment` 中的权益与到期时间。 ```ts const credits = new CreditsService(); const payment = new PaymentService({ providers: [stripePaymentProvider()], resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000, }), on_paid: async (record, fulfillment) => { if (fulfillment.entitlement.kind !== "topup") { throw new Error("此接入仅支持自由充值"); } 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}`, note: record.note, }); }, }); ``` 用户直接创建支付订单,无需先在 Credits 中创建 pending Topup: ```ts await city.payment.method("stripe").invoke({ topup_amount_minor: 500, idempotency_key: "order_123", note: "购买 Credits", }); ``` 用户提交希望支付的金额,`resolve_topup` 在 Federation 服务端计算并冻结本单 Credits,客户端不能指定兑换比例。支付确认必须依赖验签事件或 Provider API 的可信金额、币种与外部交易标识,并与本地订单快照核对;请求 metadata 不能充当实付证明。税费、折扣和换汇的金额口径必须明确,无法确认或不匹配时阻止发放并转人工核对。所有 `paid` 事件统一要求可信金额、币种与外部交易标识,缺失或不匹配以 422 转入 `review`。Stripe 按 subtotal 减 discount 投影本金并核对总额等式;Creem 使用签名订单的 amount_paid(存在时优先)或 amount;Waffo 严格按币种精度转换签名十进制金额;Dodo 当前强制一次性动态金额、同币种、无折扣/PPP 且税含定价,使用签名 total_amount 核对。具体字段和边界见各 Provider 页面;不承诺通用税费、折扣或换汇自动支持。 ## 使用账户钱包 在[账户钱包](/zh/account?view=wallet)查看总可用额度与 Credits 卡片。唯一 Primary 永久有效,右下角可充值;限时卡显示剩余额度、到期时间和状态。点击卡片查看流水,右上角「购买限时卡」选择套餐,「订单」查看付款与发放详情。过期卡显示最后记录余额,已经不计入可用额度;流水保留当时的余额事实。 普通充值进入永久 Primary;限时卡使用外部支付直接购买,不扣现有 Credits。管理员在 Fedman「限时卡套餐」配置名称、币种、minor 金额、Credits、有效秒数和启停状态。未配置时没有在售商品,本地 Demo 的示例价格不会进入正式环境。 购买只提交服务端 `plan_id`、`method_id` 和稳定 `idempotency_key`。后续改价、改额度或停售不影响已下单权益。有效期从持久化的首次履约时间开始,重试沿用同一时间。success 回跳页面只提示查询订单,不会发放额度;已付款但发放失败时可以重试发放。 当前钱包不提供自动退款、拒付追偿、余额换卡或自动续费。停售不表示退款;付款争议需联系服务运营方。 ## 事件收件箱与恢复 Webhook 验签并规范化后,先持久化可信事件到 inbox,再入队,成功后返回 HTTP 200;这表示已接收,不表示发放完成。入队失败返回错误,但已保存的事件可由恢复扫描重新唤醒。Queue 执行 `payment.events/process`,重放使用保存的规范化事件,不重新验证旧 HTTP 请求的签名。 宿主必须周期调用 `payment.recover_events()`,或由管理员调用 `POST /v1/payment/events/recover`,扫描到期 pending 和租约过期 processing。当前 Edge Worker 每五分钟执行扫描,部署配置通过 `federation.json` 的 `deployment.crons` 声明计划。 临时失败从 15 秒开始指数退避,延迟上限一小时,最多尝试 20 次后进入 `review`。金额校验失败(422)直接进入 `review`,不无限自动重试。 管理员通过 `GET /v1/payment/events/inbox` 查看最近 100 条记录、状态、尝试次数与错误诊断;修复原因后调用 `POST /v1/payment/events/replay`,JSON 为 `{ "event_id": "provider:event_id" }`。重放会再次经过订单校验与幂等发放,不绕过金额校验。`on_paid(record, fulfillment)` 必须使用稳定 `payment_id` 及权益快照实现幂等,避免重试重复发放。 ### 接入与调用 Source: https://downcity.ai/zh/payments/usage/calling/ 这篇文档展示一条完整链路:用户调用 AI 后,`AIService` 保存技术用量并可靠提交 Credits 扣费;产品再通过 `UsageService` 查询当前用户的每日数据。 ```text 用户调用 AI → AIService 获取最终 RuntimeMetering → 保存 AI Usage → CreditsService 幂等扣费 → UsageService 按当地自然日聚合两类事实 → GET /v1/usage/me ``` ## 1. 安装依赖 ```bash pnpm add @downcity/federation @downcity/services ``` 数据库 package 根据部署环境选择,例如本地 Node 使用 `@downcity/database-sqlite`,Cloudflare Workers 使用对应的 D1 Adapter。 ## 2. 在 Federation 装配服务 `UsageService` 必须显式接收两个 Reader。推荐按 Credits、AI、Usage 的顺序挂载: ```ts import { AIService, Federation } from "@downcity/federation"; import { Database } from "@downcity/database-sqlite"; import { CreditsService, UsageService } from "@downcity/services"; const database = new Database({ filename: "./data.sqlite" }); const federation = new Federation({ database }); const credits_service = new CreditsService(); const ai_service = new AIService({ credits: credits_service }); federation.use(credits_service); federation.use(ai_service); federation.use(new UsageService({ ai_usage_reader: ai_service, credits_usage_reader: credits_service, })); await federation.health(); ``` 职责边界如下: - `AIService` 拥有 Token、图片、视频和音频等技术用量。 - `CreditsService` 拥有已经入账的 Credits 交易。 - `UsageService` 不创建事实表,只合并两个 Reader 的每日结果。 不要单独使用 `new UsageService()`;两个 Reader 都是必填依赖。 ## 3. 给模型配置 Credits 账单 模型的 `bill` 在执行结束后接收最终 `metering`。下面只是示例价格,实际 Credits 规则由产品定义: ```ts ai_service.use(ai_channel.model({ id: "example-chat", upstream_model: "provider-model-id", name: "Example Chat", bill: ({ usage_id, metering }) => { if (!metering) return; const input_credits = (metering.input_tokens ?? 0) * 2; const output_credits = (metering.output_tokens ?? 0) * 8; return { credits: input_credits + output_credits, note: "Example Chat usage", metadata: { usage_id, model_id: metering.model_id, channel_id: metering.channel_id, }, }; }, })); ``` 语言模型的 Token 来自 AI SDK 标准流的最终 `finish.usage`。其他模态由 Channel 返回的最终 `RuntimeMetering` 提供,例如 `image_count`、`video_seconds` 和 `audio_seconds`。 AI Usage 和 Credits Charge 使用同一个 `usage_id` 关联。Credits 扣费使用 `ai:${usage_id}` 作为幂等键,因此 Queue 重投递不会重复扣费。 ## 4. 先产生一笔 AI 用量 终端用户使用自己的 `user_token` 调用 AI: ```ts import { Embassy } from "@downcity/federation"; const embassy = new Embassy({ federation_url: "https://federation.example.com", user_token, }); const catalog = await embassy.user.ai.catalog(); const model = catalog.get("example-chat"); if (!model) throw new Error("example-chat is unavailable"); await embassy.user.ai.text({ model, prompt: "请总结今天的项目进度", }); ``` 非流式调用在返回前完成结算。流式调用会在流成功结束、失败或取消后固化执行结果;只有 Provider 返回可信最终计量时才会累计 Token 或媒体用量。 ## 5. 使用 SDK 查询当前用户 ```ts import type { UserUsageResponse } from "@downcity/services"; const usage = await embassy.user.service("usage").get("me", { from: "2026-08-01", to: "2026-08-31", timezone: "Asia/Shanghai", }); console.log(usage.summary.credits.used); console.log(usage.summary.ai.total_tokens); console.log(usage.days); ``` 查询参数: | 参数 | 必填 | 说明 | | --- | --- | --- | | `from` | 是 | 起始当地自然日,格式 `YYYY-MM-DD`,包含当天 | | `to` | 是 | 结束当地自然日,格式 `YYYY-MM-DD`,包含当天 | | `timezone` | 是 | IANA 时区,例如 `Asia/Shanghai`、`America/Los_Angeles` | 单次查询最多覆盖 400 个当地自然日。用户身份只从 `user_token` 获取,API 不接受 `user_id` 参数,也不能查询其他用户。 ## 6. 直接使用 HTTP API ```bash curl --get "https://federation.example.com/v1/usage/me" \ --header "Authorization: Bearer $USER_TOKEN" \ --data-urlencode "from=2026-08-01" \ --data-urlencode "to=2026-08-31" \ --data-urlencode "timezone=Asia/Shanghai" ``` 接口固定为: ```http GET /v1/usage/me?from=2026-08-01&to=2026-08-31&timezone=Asia%2FShanghai Authorization: Bearer ``` 旧的 `/v1/usage/events` 和 `/v1/usage/summary` 不再提供。 ## 7. 响应示例 ```json { "timezone": "Asia/Shanghai", "from": "2026-08-01", "to": "2026-08-31", "credits_per_usd": 1000000, "data_available_from": { "credits": "2026-08-02", "ai": "2026-08-02" }, "summary": { "credits": { "used": 3500, "charge_count": 3 }, "ai": { "execution_count": 3, "metered_request_count": 3, "uncached_input_tokens": 900, "cached_input_tokens": 100, "input_tokens": 1000, "output_tokens": 400, "reasoning_tokens": 120, "total_tokens": 1400, "image_count": 0, "video_seconds": 0, "audio_seconds": 0 } }, "days": [ { "date": "2026-08-02", "credits": { "used": 2500, "charge_count": 2 }, "ai": { "execution_count": 2, "metered_request_count": 2, "uncached_input_tokens": 700, "cached_input_tokens": 100, "input_tokens": 800, "output_tokens": 300, "reasoning_tokens": 100, "total_tokens": 1100, "image_count": 0, "video_seconds": 0, "audio_seconds": 0 } } ] } ``` `days` 是稀疏数组:只有 Credits 或 AI 任一侧存在活动的日期才会返回。如果当天只有一侧有数据,另一侧会返回完整的零值 Bucket。 字段使用建议: - 消费总览:`summary.credits.used` - Credits 热力图:`days[].credits.used` - 总 Token:`summary.ai.total_tokens` - 每日 Token 趋势:`days[].ai.total_tokens` - 调用次数:`summary.ai.execution_count` - 有最终计量的请求数:`summary.ai.metered_request_count` `execution_count` 可能大于 `metered_request_count`。例如流被取消、Provider 失败或没有返回最终计量时,系统仍记录一次执行,但不会虚构 Token 数。 ## 8. 转换成热力图数据 Credits 是产品用量图的主指标: ```ts const heatmap_data = usage.days.map((day) => ({ date: day.date, value: day.credits.used, charge_count: day.credits.charge_count, })); ``` 如果组件要求连续日期,需要由前端补齐查询区间中的空白日期: ```ts const credits_by_date = new Map( usage.days.map((day) => [day.date, day.credits.used]), ); const value_for_date = (date: string) => credits_by_date.get(date) ?? 0; ``` 不要根据 Token 推算 Credits,也不要根据 Credits 反推 Token。它们来自两个独立事实源,价格调整、免费调用或非 Token 模态都会让两者不再线性对应。 ## 9. 常见错误 | HTTP 状态 | 原因 | 处理方式 | | --- | --- | --- | | `400` | 日期不存在、`from > to`、超过 400 天或时区无效 | 修正查询参数 | | `401` | 缺少或无法验证 `user_token` | 重新登录或刷新 Token | | `500` | AI Usage 或 Credits Reader 查询失败 | 检查数据库和 Federation 日志后重试 | 若 Credits 已扣除但短时间内没有出现在查询中,先确认交易状态是否为 `applied`。Usage 不统计充值、退款、待处理或失败的 Credits 交易。 ## 10. Worker 部署注意事项 可靠结算任务以数据库为事实源。Cloudflare Workers 应配置 Queue Adapter,并把消息交回 `federation.queue.call(message.body)`;这样 Credits 或数据库发生短暂技术错误时可以继续唤醒重试。使用 Downcity Worker 模板创建项目时,这部分装配已经包含在模板中。 ### Usage 事实与计量模型 Source: https://downcity.ai/zh/payments/usage/event-model/ Usage 页面由两个独立事实源组成: - `AIService` 保存一次 AI 执行的最终技术计量,包括 Token、图片数、视频秒数和音频秒数。 - `CreditsService` 保存已经入账的 Credits Charge。 两者通过稳定的 `usage_id` 关联,但不互相派生。一次执行可以有 AI 技术记录而没有扣费,也不能根据 Token 数反推出 Credits。 ## AI 每日字段 - `execution_count`:AI 执行次数,包括没有最终计量的失败或取消执行。 - `metered_request_count`:取得可信最终计量的请求数。 - `input_tokens`、`output_tokens`、`reasoning_tokens`、`total_tokens`:AI Service 最终返回的 Token 明细。 - `image_count`、`video_seconds`、`audio_seconds`:媒体用量。 流式请求只有在流成功结束、失败或取消后才固化最终状态。没有可信最终计量时仍计入执行次数,但不会虚构 Token 或媒体用量。 ## Credits 每日字段 只聚合 `kind = charge`、`status = applied` 且 Credits 大于零的账务记录: - `used`:已消费 Credits。 - `charge_count`:已入账扣费次数。 充值、退款、待处理或失败交易不会进入消费热力图。 ### 查询与汇总 Source: https://downcity.ai/zh/payments/usage/querying-and-summary/ Usage 只公开当前用户入口:`GET /v1/usage/me`。身份来自 `user_token`,不能通过参数查询其他用户。 ```ts const usage = await city.service("usage").get("me", { from: "2026-03-01", to: "2026-03-31", timezone: "America/Los_Angeles", }); ``` 日期按传入时区的当地自然日划分,包括夏令时切换日。参数必须是真实的 `YYYY-MM-DD` 日期,范围必须有序且不超过 400 天。 ## 响应结构 ```ts { timezone: "America/Los_Angeles", from: "2026-03-01", to: "2026-03-31", credits_per_usd: 1_000_000, data_available_from: { credits: "2026-03-02", ai: "2026-03-02" }, summary: { credits: { used: 2500, charge_count: 3 }, ai: { execution_count: 3, metered_request_count: 3, total_tokens: 12400 } }, days: [/* 仅包含有 Credits 或 AI 活动的日期 */] } ``` 产品中的热力图和趋势图应优先使用 `days[].credits.used`。Token 和媒体字段用于解释实际 AI 消耗,不应被换算成 Credits,也不能从 Credits 反推。 `data_available_from` 分别表示两个事实源最早可用的当地日期;无数据时为 `null`。 ### Usage 与 Credits 计费 Source: https://downcity.ai/zh/payments/usage/billing-integration/ 模型或 Channel 的 `bill` 根据最终输出生成 Credits 账单。`AIService` 使用同一个 `usage_id` 持久化 AI Usage,并以 `ai:${usage_id}` 作为幂等键调用 `CreditsService`。 ```ts const credits_service = new CreditsService(); const ai_service = new AIService({ credits: credits_service }); ai_service.use({ id: "example-model", channel_id: "example-provider", name: "Example Model", bill: ({ output }) => ({ credits: calculate_credits(output), note: "AI model usage", }), runtime: provider_runtime, }); ``` 余额不足会拒绝结算;数据库或 Credits 技术错误会进入可重试状态。重试不会重复写 AI Usage,也不会重复扣费。Usage 查询只展示已经 `applied` 的 Credits Charge,因此账务趋势始终以 Credits Ledger 为准。 ### Stripe Payment Provider Source: https://downcity.ai/zh/payments/payment-stripe/ ```ts const payment = new PaymentService({ providers: [stripePaymentProvider()], resolve_topup: ({ topup_amount_minor }) => ({ credits: topup_amount_minor * 10_000 }), on_paid: async (record, fulfillment) => { if (fulfillment.entitlement.kind !== "topup") { throw new Error("此接入仅支持自由充值"); } 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}`, }); }, }); ``` 必须同时配置 `STRIPE_SECRET_KEY` 和 `STRIPE_WEBHOOK_SECRET`,否则 Provider 不可用且 webhook 会被拒绝。webhook 地址为 `/v1/payment/webhook?provider=stripe`。 本例仅处理自由充值。`paid` 与 wallet 履约独立,`on_paid(record, fulfillment)` 使用订单权益快照。验签事件先持久化并入队再确认接收;已验签 Checkout 事件按 `amount_subtotal - total_details.amount_discount` 投影充值本金,各金额分量必须为非负安全整数,并满足 `amount_total = 本金 + amount_tax + amount_shipping`;独立税费和运费不购买 Credits。本金、币种必须匹配本地订单,且必须提供外部交易标识。金额分量缺失、不匹配或含税/混合定价无法满足该投影时,以 422 阻止发放并将事件转入 `review`。缺少本金明细的 PaymentIntent 事件不能独立授权发放。请求 metadata 不能作为实付证明,不自动换汇。恢复、诊断与管理员重放见 [Payment 入账](/zh/payments/credits/payment-integration)。 ```ts await city.payment.method("stripe").invoke({ topup_amount_minor: 500, idempotency_key: "order_123", }); ``` ### Creem Payment Provider Source: https://downcity.ai/zh/payments/payment-creem/ 将 `creemPaymentProvider()` 放入 `PaymentService.providers`,必须配置 `CREEM_API_KEY`、`CREEM_PRODUCT_ID` 和 `CREEM_WEBHOOK_SECRET`。`CREEM_PRODUCT_ID` 必须指向一次性、税费包含在价格内的产品,`CREEM_CURRENCY` 必须与该产品币种一致。Payment 会使用服务端订单金额作为 Creem `custom_price`,其有效范围是 100–99,999,999 个最小货币单位。缺少验签密钥时 Provider 不可用。用户创建订单: ```ts await city.payment.method("creem").invoke({ topup_amount_minor: 500, idempotency_key: "order_123", }); ``` Payment 通过 `on_paid(record, fulfillment)` 按订单快照履约,自由充值使用 `payment:${record.payment_id}` 幂等调用 `credits.topup()`。`paid` 与 wallet 履约状态独立。付款确认优先读取已验签事件的 `order.amount_paid`,不存在时读取 `order.amount`,币种取 `order.currency`。金额必须是最小货币单位的非负安全整数,金额、币种必须匹配本地订单,且必须提供外部交易标识。事实缺失、无效或不匹配时以 422 阻止发放,事件进入 `review`。请求 metadata 不能充当实付证明,不自动核算通用税费、折扣或换汇方案。验签事件先持久化并入队再确认接收;恢复与管理员重放见 [Payment 入账](/zh/payments/credits/payment-integration)。 ### Dodo Payment Provider Source: https://downcity.ai/zh/payments/payment-dodo/ 将 `dodoPaymentProvider()` 放入 `PaymentService.providers`,配置 Dodo API Key、Product ID 与 webhook key。 ```ts await city.payment.method("dodo").invoke({ topup_amount_minor: 500, idempotency_key: "order_123" }); ``` 创建 checkout 前,Provider 读取配置的产品,强制要求一次性 `pay_what_you_want` 定价、与订单同币种、无折扣、无购买力平价(PPP),且 `tax_inclusive: true`。产品最低价格不得高于订单金额,不符合条件时拒绝创建收银台。 付款确认读取已验签支付事件的 `total_amount`、`currency`,并要求外部交易标识,金额和币种必须匹配本地订单。当前 Dodo 实现严格要求税含定价,不自动支持通用税费、折扣或换汇方案。确认事实缺失、无效或不匹配时以 422 阻止发放,inbox 事件进入 `review`,等待人工核对。 PaymentService 保存订单与事件,Credits 只在 `on_paid` 中接收已确认额度。 ## Webhook 将 Dodo Dashboard 的 webhook 地址设置为 `/v1/payment/webhook?provider=dodo`,并至少订阅支付生命周期事件: - `payment.processing` - `payment.succeeded` - `payment.failed` - `payment.cancelled` Dodo 遵循 Standard Webhooks。Provider 使用官方 SDK 和原始请求 body 校验 `webhook-id`、`webhook-timestamp`、`webhook-signature`,并严格以唯一的 `webhook-id` 处理幂等。缺少任一协议 Header 或签名无效时,请求都会被拒绝。 同一支付可能产生多个生命周期事件,网络重试也可能乱序到达。验签事件先持久化到 inbox 再入队,HTTP 200 只表示已接收。`payment.succeeded` 是付款确认信号,发放前必须将可信支付事实与订单核对,不能用请求 metadata 充当实付证明。`paid` 与 wallet 履约独立,`on_paid(record, fulfillment)` 必须使用 `payment_id` 幂等处理。金额校验要求、恢复扫描、诊断与管理员重放见 [Payment 入账](/zh/payments/credits/payment-integration)。 ### Waffo Payment Provider Source: https://downcity.ai/zh/payments/payment-waffo/ 将 `waffoPaymentProvider()` 放入 `PaymentService.providers`,配置 Merchant ID、Private Key、Product ID 与 webhook public key。 ```ts await city.payment.method("waffo").invoke({ topup_amount_minor: 500, idempotency_key: "order_123" }); ``` 支付确认后由 `on_paid(record, fulfillment)` 按订单快照幂等履约,自由充值发放 Primary Card Credits。`paid` 与 wallet 履约状态独立。付款确认读取已验签的 `orderAmount`、`orderCurrency`,按币种精度将十进制金额字符串精确转换为整数最小货币单位,不使用浮点四舍五入;格式错误、超出币种精度且非零的小数及不安全整数均不接受。金额、币种必须匹配本地订单,且必须提供外部交易标识。事实缺失、无效或不匹配时以 422 阻止发放,事件进入 `review`。请求 metadata 不能充当实付证明,不自动核算通用税费或换汇方案。验签事件先持久化并入队再确认接收;恢复与管理员重放见 [Payment 入账](/zh/payments/credits/payment-integration)。 ## Powers ### Agent Powers Docs Source: https://downcity.ai/en/powers-docs/ This section is the concrete power manual. Use it when you want to understand a specific power such as `web`, `skill`, `messenger`, or `task`. If you want power design concepts first, continue with [Power Overview](/en/agent-sdk-docs/powers/overview) in Agent SDK Docs. ## What this section covers - which built-in powers currently exist in code - which built-in power classes are publicly exported from `@downcity/powers` - which ones are mainly platform-managed or infrastructure-oriented - what each power is responsible for - what lifecycle, availability, config, and action shape each power has ## Reading paths - Start with [Built-ins Overview](/en/powers-docs/builtins/overview) - Then open the power you care about - Use [Examples](/en/powers-docs/examples) when you want scenario-driven patterns ## Best mental model There are two different questions: 1. how the power system itself works 2. what one concrete power actually does This section answers the second question. ## Next reading - [Built-ins Overview](/en/powers-docs/builtins/overview) - [Examples](/en/powers-docs/examples) - [Power Concepts in Agent SDK Docs](/en/agent-sdk-docs/powers/overview) ### Built-ins Overview Source: https://downcity.ai/en/powers-docs/builtins/overview/ The current built-in power capabilities in code include: - `skill` - `web` - `sound` - `messenger` - `schedule` - `task` - `memory` - `shell` ## Package imports Each SDK-facing built-in has an isolated package subpath: | Built-in | Import path | | --- | --- | | `messenger` | `@downcity/powers/messenger` | | `memory` | `@downcity/powers/memory` | | `skill` | `@downcity/powers/skill` | | `task` | `@downcity/powers/task` | | `web` | `@downcity/powers/web` | `image` and `sound` are City capabilities rather than `@downcity/powers` subpaths, so they are configured through City instead of imported from a package subpath. Use `create_builtin_power_registrations()` for the default collection, or import and instantiate only the Powers required by a City. Selection happens at City level, not per Agent. The `skill` Power owns the model actions `list`, `lookup`, `servers`, `tools`, and `call`. Its unified Sidebar lists Skills and MCP resources; **Agent Profile → Skills** manages skill links and MCP assignments. Global MCP server definitions and credentials remain in their existing storage. See [Skills](/en/powers-docs/builtins/skill) and [MCP servers](/en/docs/power/mcp). ## Two groups matter ### Public SDK-facing built-ins These are the ones most developers are likely to read first: - `skill` - `web` - `sound` - `messenger` `sound` requires the FED model catalog plus ASR and TTS functions through its constructor. Built-in powers are assembled explicitly from their individual package subpaths. These are the clearest examples for: - action powers - hook or resolve powers - system text powers - lifecycle powers that normal SDK users may still understand directly ### Platform-managed or infrastructure built-ins These are also built-ins in code, but they are more platform-oriented: - `schedule` - `task` - `memory` - `shell` These powers are usually read to understand how the platform is assembled, not because every SDK user should manually attach them to a local `Agent`. ## How to read this section - If you want user-facing capability powers, start with `skill`, `web`, `sound`, and `messenger` - If you want platform rule layers, read the built-in authorization capability in `messenger` - If you want infrastructure patterns, read `schedule`, `task`, `memory`, and `shell` ## Responsibility map | Power | Main role | Shape | | --- | --- | --- | | `skill` | skill discovery and content loading, MCP discovery/calls, and per-Agent capability assignments | lifecycle + actions + system | | `web` | web methodology injection and dependency preparation | action + system | | `sound` | FED speech-model discovery, ASR, TTS, and inbound auto-transcription | action + pipeline + system | | `messenger` | channel runtime, queue worker, messenger authorization, and role resolution | lifecycle + actions + hooks + resolves + system | | `schedule` | power action scheduling infrastructure | lifecycle-only infrastructure | | `task` | task scheduler and cron execution | lifecycle + actions + system | | `memory` | provider-neutral long-term memory and recall | lifecycle + actions + system | | `shell` | shell session runtime | lifecycle + actions | ## Important distinction Do not read “built-in” as “all of these are equally public.” Some built-ins are best understood as: - public capability powers - platform integration powers - infrastructure powers That distinction is one of the reasons the power concept docs stay in Agent SDK Docs, while concrete power guides live here. ## Related concept docs - [Power Overview](/en/agent-sdk-docs/powers/overview) - [Power Design Patterns](/en/agent-sdk-docs/powers/design-patterns) - [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle) - [Power Availability](/en/agent-sdk-docs/powers/power-availability) ### Presentation Source: https://downcity.ai/en/powers-docs/builtins/presentation/ Presentation lets Agents use charts, comparison cards, tables, and parameter controls in Desktop replies. Ask for a compound-interest chart with an adjustable rate, or a visual comparison of your options. Text and modules follow their creation order. A successful module replaces its redundant successful tool activity; running, failed, and waiting-for-response states remain visible. Modules follow Desktop typography, theme, and control radii, with a single thin border, prominent key metrics, slim sliders, and compact controls. Standard cards stay transparent, tables use compact spacing, and charts soften supporting grids while retaining series colors. Newly generated modules favor a title, metrics, chart, controls, and actions, avoiding repeated headings and nested decorative cards. Text inside modules uses the same Markdown dialect as reply prose: tables render as real tables, `mermaid` fences render as zoomable, fullscreen-capable diagrams, math goes through KaTeX, and local images reuse the controlled file protocol. ## Generation and interaction Local parameters, filtering, reset, and submitting the current parameters to the Agent are supported. Changing a control does not call the model. An explicit submit action continues the conversation. Recent view state is stored locally in Desktop and does not sync across devices. A module at the message tail can appear progressively as complete statements arrive. Controls stay disabled until final validation. This requires tool-argument deltas from the model provider. Unsupported or ordering-sensitive paths display the completed module instead. After publication, the reply can finish and the interface remains interactive; the Agent does not need to wait for a click. Distinct requested modules can be published in sequence. Successful content does not need to be generated again for confirmation. ## Action `presentation.show` accepts `content` and `fallback_text`. Content is OpenUI Lang with positional arguments. Declare root first, then define referenced components and data. ```json { "content": "root = Stack([header, chart, buttons])\nheader = CardHeader(\"Growth comparison\")\nchart = LineChart([\"2024\", \"2025\"], [Series(\"Revenue\", [10, 20])])\nbuttons = Buttons([Button(\"Explain\", @ToAssistant(\"Explain these results\"))])\n", "fallback_text": "Revenue increased from 10 in 2024 to 20 in 2025." } ``` The catalog exposes 74 components including companion items: product/concept/overview cards, key/value and content lists, steps, carousel, icons and icon buttons, Markdown, code, images and galleries, plus the existing layouts, tables, charts, and metrics. Inputs include dates, radio/checkbox groups, switches, chips, and option cards. Form submission includes current field values. `$variables` hold local state; `@Set` updates it, `@Reset` restores defaults, and `@ToAssistant` submits a question. `@CompoundValues` and `@YearLabels` support compound interest: principal 0–10¹², decimal annual rate -1–1, and integer years 1–100. ## Limits and fallback Images accept genuine http(s) URLs, inline raster data, or authorized absolute local paths through Desktop's controlled file protocol. Relative paths must first be resolved to absolute paths. Loading failures preserve text; use text-only cards when no real image exists. Code is displayed, never executed. Remote Query / Mutation, arbitrary business execution, and navigation are not exposed. EditableTable, Modal, and additional chart types remain unavailable. Content is limited to 240 KiB, 200 statements, and 32 nesting levels. Submitted parameters are limited to 16 KiB. Missing renderers, unsupported versions, or component failures show the summary. Historical JSON Spec modules use text fallback too. This release uses MIT-licensed OpenUI without requiring its hosted model services. ### Skills Source: https://downcity.ai/en/powers-docs/builtins/skill/ `skill` manages Skills and MCP resources in one Power. Each Agent has its own skill directory, which is the authoritative set of skills it can use. The Power discovers those skills, reads `SKILL.md`, injects the inventory into the system prompt, and exposes tools from MCP servers explicitly assigned to that Agent. Skills live at `~/.downcity/agents//skills`. Every entry is a directory containing `SKILL.md`. An entry is either a copy installed into the Agent directory or a symlink pointing at a skill that lives elsewhere. The directory sits inside the Agent's own data root, so removing the Agent removes its skills with it. Writing into it is granted to the Agent's shell, which means the Agent can install and link skills itself. The root path comes from the container (`PowerContext.data_root_path` / `PowerLifecycleContext.data_root_path`), not from a constructor option. ## Scope The Agent's own directory is the default and authoritative source. Two extra sources are readable on demand: - `project` reads `/.agents/skills` - `home` reads `~/.agents/skills` - `all` returns the union of the Agent directory, project, and home When the same skill ID appears in more than one source, the Agent directory wins, then project, then home. `list` and `lookup` accept the same `scope` argument, and both default to the Agent directory. The system prompt inventory lists the Agent directory only, so every skill name shown to the model is directly usable without a scope argument. ## Actions | Action | Payload | Purpose | | --- | --- | --- | | `list` | `{ scope?: "agent" \| "project" \| "home" \| "all" }` | List skills in the given scope; defaults to the Agent directory | | `lookup` | `{ name: string, scope?: ... }` | Read a skill's `SKILL.md` | | `servers` | `{}` | List MCP servers assigned to the calling Agent and their status, including disabled or invalid definitions | | `tools` | `{ server_id: string }` | Read tool names and input schemas from an assigned MCP server | | `call` | `{ server_id: string, tool_name: string, arguments: object }` | Invoke a tool on an assigned MCP server | `lookup` returns the skill body as a standard context message, so the content reaches the model on the next step. It does not persist any session state. There are no `find` or `install` actions. Installation and linking happen either through the Agent's own shell or from the Desktop, because the `skills` CLI cannot install directly into an Agent skill directory and the copy step needs a decision about what lands there. The Agent installs in two steps: run the `skills` CLI inside a temporary directory, then copy the resulting skill into its own directory. The system prompt carries the exact commands, including the `npm_config_cache` override that keeps `npx` working inside the sandbox. Reading `project` or `home` through `scope` does not make those skills available. A skill that lives in the user directory or a workspace becomes usable only after it is copied or linked into the Agent directory, and that entry is what `list` reports by default. Listing the Agent's skills: ```ts const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({ power: "skill", action: "list", payload: {}, }); ``` Reading a skill: ```ts const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({ power: "skill", action: "lookup", payload: { name: "web-access", }, }); ``` The model uses the same protocol inside a Session: ```ts skill({ action: "lookup", args: { name: "web-access", }, }); ``` ## Configuration The Skill Config panel manages global MCP server definitions. Existing definitions and OAuth credentials remain under `~/.downcity/powers/mcp/`; merging the interface does not move them. See [MCP configuration](/en/docs/power/mcp) for server file formats and connection behavior. Skill discovery scope is determined by the Agent directory plus the `scope` argument. Use `ignore` to filter skills: ```ts const skill_power = new SkillPower({ ignore: ["legacy-skill", /^internal-/], }); ``` - A string matches both the skill ID and its name. - A `RegExp` filters in bulk. - A predicate returning `true` excludes the skill. ## Desktop The unified Skill Sidebar lists individual resources under **Skills** and **MCP**, without Agent grouping. Select a Skill to inspect its content, source path, and Agent usage, or an MCP server to inspect connection status, tools, and errors. Skills with the same ID in different directories remain separate resources, with their source shown in the Sidebar. Links resolving to the same directory share one resource and its Agent usage. In **Agent Profile → Skills**, add skills from the user catalog, remove assigned skills, and enable MCP servers for that Agent. Global server enablement alone does not grant Agent access. Assignments are stored as a JSON array of server IDs at `~/.downcity/agents//mcp-servers.json`; a missing file grants no MCP access. The host actions `skill.mcp.assign` and `skill.mcp.unassign` accept `{ agent_id, server_id }`. These manage assignments through the Power action gateway; they are not model actions. Model calls use `skill` with `servers`, `tools`, and `call`, and cannot access unassigned servers. The only linkable source is the user directory. A workspace `.agents/skills` does not appear in the interface: to give a skill to an Agent, put it in the user directory first. Removing a linked skill deletes only the symlink. Operations stay inside the Agent directory. ## Related documentation - [Built-in Powers overview](/en/powers-docs/builtins/overview) - [Power actions](/agent-sdk-docs/powers/power-actions) - [Power system](/agent-sdk-docs/powers/power-system) ### web Power Source: https://downcity.ai/en/powers-docs/builtins/web/ `web` owns its search, document, and browser providers. It does not depend on `context.agent.web`. In Desktop, static document reading and a managed local Chrome browser work without manual CDP setup. Search becomes available after an API key is configured. ## Desktop configuration Open **Powers → Web**. Each capability is configured in place: pick its provider from the dropdown on the capability row in the workspace Sidebar, then edit that capability's parameters in the Mainview. - Search: `Auto`, `Tavily`, `Exa`, `DeepSeek`, or disabled. Auto selects the first configured provider in this order: Tavily → Exa → DeepSeek. - Documents: built-in Fetch by default, or Firecrawl or DeepSeek. - Browser: managed Local Chrome by default, Remote CDP, or disabled. - Advanced: operation timeout, observation character limit, default browser URL, and an optional Chrome path. API keys can be entered directly in a capability view. Saved values are never returned to the renderer; the page only shows whether a key is configured. An empty field preserves the existing value, while the explicit Clear action removes it. A key that is not configured yet shows a **去申请** link that opens the provider's console, so you do not have to search for it. Keys currently live in Downcity's local Power configuration and are not stored in the operating-system keychain. Global Env remains supported: ```dotenv TAVILY_API_KEY=... EXA_API_KEY=... DEEPSEEK_API_KEY=... FIRECRAWL_API_KEY=... ``` Power configuration takes precedence over Global Env. Each capability section includes a test button that saves the draft and performs one minimal call through a real Agent and Workspace. ## Desktop workspace Web contributes a first-class entry to the Desktop navigation rail with a Sidebar and a Mainview. It declares no separate Config panel: each capability is configured in place. - **Sidebar** groups capabilities under **能力**, with a **通用** entry at the end. Each capability is a branch with a mode tag on the right (`Auto`, `Fetch`, `已关闭`, or `待配置`), expanding to its providers. Each provider row carries a status dot: green when ready, amber when it still needs credentials. - **Mainview** shows one capability: an enable switch, the resolved provider in the toolbar description, and that capability's own parameters. The browser capability also lists the sessions the provider currently owns. API keys are never returned; only whether a key is configured. - **通用** holds the parameters shared by all three capabilities (operation timeout, observation character limit) and the config file path with a button to reveal it. - **Advanced** holds the parameters that span capabilities: operation timeout, observation character limit, the default browser URL, and an optional Chrome path. Search, document reading, and browser each have their own on/off switch. Turning a capability off is not the same as choosing a provider, so `Off` is not listed among the providers. Web settings are global: one configuration serves every Agent and Workspace, so the workspace asks for no execution scope. Saving is field-scoped: picking a provider submits only its own field, so it never overwrites unsaved input elsewhere on the page. Creating, observing, and closing browser sessions still happens through Agent actions, so the page never holds a browser on its own. ## Defaults ```ts import { WebPower } from "@downcity/powers/web"; const web = new WebPower(); ``` - `open` uses the built-in Fetch provider and never executes page scripts. - Browser actions discover Google Chrome, Chromium, or Microsoft Edge and use a private persistent profile. - `search` selects Tavily, Exa, or DeepSeek when a key is available; missing search credentials do not disable other capabilities. WebPower closes browser processes and pages it owns. The persistent profile keeps login state. For a cloud browser, select Remote CDP and enter its HTTP or WebSocket endpoint. ## Browser proxy Local Chrome applies the network proxy resolved by Desktop: explicit Desktop settings, then system proxy settings, then Global Env. SDK / CLI usage reads `DOWNCITY_PROXY_URL`, `HTTPS_PROXY`, `HTTP_PROXY`, and `ALL_PROXY` in that order, including lowercase forms of standard variables. With no configured proxy, Chrome follows the system network settings. Local browsers support HTTP, HTTPS, SOCKS4, and SOCKS5 proxy URLs without usernames or passwords. Bypass rules come from `DOWNCITY_NO_PROXY`, `NO_PROXY`, and `no_proxy`; loopback addresses always connect directly. Existing browser sessions stay open after a proxy change. Find them with `browser_list_sessions` and close them with `browser_close_session`. Creating the next session restarts an owned browser with the new settings. If a running Web Power browser has different or unknown proxy settings, close its Chrome window and retry. Remote CDP proxy settings are managed by the owner of the remote browser. ## Fixed SDK configuration Constructor configuration overrides saved City/Desktop configuration: ```ts const web = new WebPower({ config: { search_provider: "tavily", tavily_api_key: process.env.TAVILY_API_KEY, document_provider: "firecrawl", firecrawl_api_key: process.env.FIRECRAWL_API_KEY, browser_provider: "cdp", cdp_url: "https://browser.example.com/cdp", }, }); ``` Hosts may still inject `search_provider`, `document_provider`, or `browser_provider_factory`. Ownership of injected providers transfers to WebPower. ## DeepSeek search and document reading Select `DeepSeek` separately for search and documents. Both capabilities share one DeepSeek API key; saving or clearing it in either view affects both. You can also set it through the SDK: ```ts const web = new WebPower({ config: { search_provider: "deepseek", document_provider: "deepseek", deepseek_api_key: process.env.DEEPSEEK_API_KEY, }, }); ``` - `search` calls DeepSeek Search, preferring the longer summary intended for models and preserving the original ranking score. `limit` returns up to 50 results. - Official query operators such as `site:`, `-site:`, `lang:`, `after:`, and `before:` are supported. `domains` becomes `site:` filters; use punycode for international domain names. - The full query, including domain filters, must fit within 128 Unicode characters. Longer queries return an error; shorten the keywords or reduce the domain list. - `open` calls DeepSeek Fetch for a title and Markdown body, respecting the `max_chars` limit. - Search and Fetch each have an account-level limit of 5 QPS. Fetch also has a per-domain limit. Exceeding a limit returns HTTP 429. - HTTP `408`, `429`, and `5xx` responses are retried at most twice. Requests honor `Retry-After`, falling back to exponential backoff with random jitter. All attempts and waits share the configured operation timeout. If the required wait exceeds the remaining budget, the last HTTP error is returned immediately. Other `4xx`, network failures, and invalid responses are not retried. - The published price is CNY 5 per 1,000 successful requests for each API. Failures, empty search results, and responses without document content are not billed. The test button makes a real API request. See the [official DeepSeek guide](https://api-docs.deepseek.com/guides/search) for the API and current pricing. ## Actions | Action | Purpose | |---|---| | `status` | Read the search, document, and browser providers actually active for the current scope | | `search` | Search through Tavily, Exa, DeepSeek, or an injected provider | | `open` | Read through safe Fetch, Firecrawl, DeepSeek, or an injected provider | | `browser_create_session` | Create a browser session | | `browser_observe` | Read URL, title, accessibility snapshot, visible text, refs, and an optional screenshot | | `browser_act` | Perform a deterministic action with an observation generation and ref | | `browser_semantic_act` | Use an optional semantic adapter | | `browser_extract` | Extract text from a selector or the body | | `browser_semantic_extract` | Extract through an optional semantic adapter | | `browser_close_session` | Close a session and release its page | | `browser_list_sessions` | List the browser sessions the provider still owns | Observe first, then act with a ref from that exact generation: ```ts const observed = await web.actions.browser_observe.execute({ context, input: { session_id }, }); await web.actions.browser_act.execute({ context, input: { session_id, action: { type: "click", ref: observed.data.elements[0].ref, observation_generation: observed.data.observation_generation, }, }, }); ``` Element refs are valid only for the observation generation that produced them. Use new refs after every action. CSS selectors remain available only for explicit deterministic automation. ## Network and safety boundaries - Fetch only permits public HTTP(S) targets and revalidates every redirect against SSRF rules. - Official Tavily, Exa, Firecrawl, and DeepSeek API requests reject redirects to prevent credentials from being forwarded to another address. - Requests have timeout, redirect-count, and response-size limits. - Provider errors do not expose API keys, authentication headers, or full error response bodies. - Consequential actions such as submit, send, purchase, upload, and delete still require host approval. - Page content is always untrusted input and cannot override Agent or host instructions. ## Related docs - [Built-in Power overview](/en/powers-docs/builtins/overview) - [Power Actions](/en/agent-sdk-docs/powers/power-actions) - [Power System](/en/agent-sdk-docs/powers/power-system) ### sound Power Source: https://downcity.ai/en/powers-docs/builtins/sound/ `sound` is the unified speech capability power. It discovers models whose FED modalities include `asr` or `tts`, then performs the actual call through City AI Service. `sound` does not download or run local speech models. It has no Python, Whisper, Piper, or other local inference dependency. A local audio file is only read and converted to a data URL before it is sent to FED. ## Configuration ```ts { default_asr_model: "asr-model-id", default_tts_model: "tts-model-id", } ``` ## Actions ### `models` List speech-capable FED models: ```ts ai({ action: "sound.models", args: { capability: "asr" }, }); ``` `capability` can be `asr` or `tts`. Omit it to return models supporting either capability. Each model's `modalities` field states its exact capabilities. ### `asr` Transcribe audio with a FED ASR model: ```ts ai({ action: "sound.asr", args: { model: "asr-model-id", audio_path: "./input.wav", }, }); ``` Provide one audio source: - `audio_path`: an absolute path or a path relative to the Agent project root - `url`: a remote audio URL - `data_url`: audio encoded as a data URL The result uses the serializable core fields from AI SDK `TranscriptionResult`: ```ts { text: string; segments?: Array<{ text: string; startSecond: number; endSecond: number; }>; language?: string; durationInSeconds?: number; } ``` ### 302.AI GLM-ASR When using the local Federation's 302.AI channel, an administrator must configure `AI302_API_KEY` and restart the service. Discover the model with `sound.models`, then call `sound.asr` with `model: "302-glm-asr-2512"`. This model accepts WAV/MP3 audio up to 30 seconds and 25 MB per request. Local audio is sent directly to 302.AI as Base64 without a public file URL. Split longer recordings and convert other formats before calling. This is single-segment transcription, not continuous audio streaming; speaker labels and word timestamps are not provided. ### `tts` Generate speech with a FED TTS model: ```ts ai({ action: "sound.tts", args: { model: "tts-model-id", text: "Welcome back", voice: "alloy", format: "mp3", }, }); ``` TTS returns an AI SDK `UIMessage`. Audio is represented by a `file` part, which the Agent saves to project resources and attaches to the assistant message. ## Default models Configure separate ASR and TTS defaults when needed: ```ts { default_asr_model: "asr-model-id", default_tts_model: "tts-model-id", } ``` If neither the action nor the power provides a model, the call fails with guidance to read `sound.models`. The power never picks a model implicitly. ## Automatic inbound transcription `auto_asr: true` transcribes inbound `voice` and `audio` chat attachments. This path cannot ask the user to choose a model, so `default_asr_model` is required: ```ts { auto_asr: true, default_asr_model: "asr-model-id", } ``` The transcript is appended to user text as `...`. A failed attachment transcription does not block the main chat flow. City projects Sound configuration into `PowerContext.config` for the current Agent. The AI service comes from `PowerContext.city.embassy.user.ai`; it does not belong to Agent. ### Messenger Power Source: https://downcity.ai/en/powers-docs/builtins/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. ## 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 (`origin.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. All final Channel Turn replies enter Outbox through `session.turn_committed`, including later Turns triggered when delegated Work Sessions return results. Background replies inherit the original calling Turn's message and thread route; newer input does not overwrite it. Each completion event creates at most one Outbox entry per Conversation, even when inputs merge. Failed Turns send a brief failure notice; stopped Turns do not send automatically. Completion events are saved in the Agent's private journal before callback delivery and retry independently on failure. Custom SDK hosts should call `agent.resume_work()` after all agents, Powers, and project resources are assembled to restore pending events. See [completion callbacks](/en/agent-sdk-docs/powers/power-hooks). 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) ### schedule Power Source: https://downcity.ai/en/powers-docs/builtins/schedule/ `schedule` is an infrastructure concept for delayed power actions. It is responsible for polling scheduled jobs and redispatching due power actions. In the current user-facing CLI, it is not a standalone power that you start, stop, or call with business actions. ## Main shape - persistent action jobs - runtime polling and recovery - no business actions ## What it is for Use action scheduling as a mental model when you want to understand: - why scheduling belongs to power lifecycle instead of the top-level agent shell - how delayed power actions are re-invoked - why it belongs to the Power runtime rather than a top-level CLI command ## How users use it Action scheduling is currently internal Power runtime infrastructure and has no standalone CLI management command. Use the `task` Power for user-managed, reusable automation. ## Important semantics - it mainly polls `PowerScheduleStore` - it restarts interrupted running jobs back to pending on startup recovery - it is a core infrastructure pattern, not a normal user-facing business power ## Public status Action scheduling is platform infrastructure rather than a normal public SDK capability power. ## Related docs - [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle) - [Built-ins Overview](/en/powers-docs/builtins/overview) ### task Power Source: https://downcity.ai/en/powers-docs/builtins/task/ `task` captures work, runs agents, and schedules recurring occurrences in one task model. TaskPower owns domain behavior without a renderer. The Desktop app provides native Inbox, Todo, and Calendar; agents can create and handle the same tasks from chat. ## Task identity Each task has a stable `task_id`. Titles are editable and may repeat. `create` returns the ID; `list` includes IDs and workflow properties. Prefer IDs for subsequent operations. Existing actions use a selector named `title`. Pass a stable task ID or a unique title. Ambiguous titles fail explicitly. ## Actions | Action | Purpose | | --- | --- | | `generate` | Use an internal model to generate a task, validate and save it, and directly apply its arrangement. Pass `task_id` to revise or retry. | | `create` | Validate and save structured task fields without an internal model or another confirmation. | | `list` | List the current agent's execution tasks and personal todos. | | `update` | Edit the title, instructions, or schedule. | | `run` | Start agent/script execution; optionally retry a recurring `occurrence_id`. | | `history` / `run_detail` | Read execution records and deliverables. | | `inbox` | Read unorganized work and unresolved items without resolving them. | | `work` | Organize, start, complete, reopen, accept, request changes, or handle a specific item. | | `status` / `enable` / `disable` | Control scheduling independently of work completion. | | `delete` | Delete work and its history; active executions prevent deletion. | For natural language, call `generate`, for example `{ "text": "Prepare Friday's demo" }`. Desktop and Agent generation share this action. Successful generation directly saves and applies the arrangement. Recording work does not start it; explicit immediate-execution requests start work, while timed or recurring work waits for its trigger. Generation failures preserve `task_id`; reuse a client `submission_id` when retrying a new submission. Prefer `create` when structured fields are already known; manual work starts through `run` only when requested. One-time triggers must include a timezone and be in the future. The originating session and delivery target are captured automatically. Create a personal todo: ```json { "title": "Prepare Friday's demo", "kind": "user" } ``` Create scheduled agent work: ```json { "title": "Daily report", "kind": "agent", "when": "0 9 * * *", "description": "Summarize daily progress", "body": "Read project records and deliver progress, blockers, and next steps. The final reply must be the report body itself." } ``` Accept a result using the task and original Inbox item IDs: ```json { "task_id": "task_example", "command": "accept", "item_id": "review:example" } ``` Accept only after explicit user confirmation. Agents should not approve their own deliverables. ## Work and execution Personal work (`kind=user`) needs no Agent or Workspace. Scheduled personal work creates a reminder. Agent/script work uses its configured execution targets. One-time agent work awaits human acceptance; recurring work completes each successful occurrence by default, with optional per-occurrence acceptance in task details. `when` supports `@manual`, `time:`, and Cron. Due dates do not trigger execution. Each recurring occurrence has an independent completion record. Automatic scheduling requires a running City. Missed one-time plans appear in Inbox; missed offline recurring triggers are not replayed. Approvals and follow-up questions remain in their original execution Sessions. Active tasks cannot yet be edited or cancelled. Agent work created in chat can return results to its original Session. Changing execution targets does not change that delivery destination. Tasks created without a Session retain results in execution history. Explicitly instruct the executing agent to use Messenger when external delivery is required. ## Host integration In browsers and Electron renderers, import `task_status_label` from `@downcity/powers/task/presentation` to display task status consistently. This entry contains only presentation functions and types, without Node.js scheduling dependencies. Import `TaskPower` from `@downcity/powers/task` in Node.js or the Electron main process; use `import type` for frontend type references to that entry. Read [Task Power](/docs/power/task) for workspace and scheduling guidance. ### memory Power Source: https://downcity.ai/en/powers-docs/builtins/memory/ City owns the single local user's base information. MemoryPower owns persistent agreements and knowledge. Local Agents share User local; switching Federation accounts does not switch identity or memories. There is no Jev gate, decision status or periodic Session formation worker. ## Scope and mode | target | profile | knowledge | | --- | --- | --- | | user | Cross-Agent user agreements | User goals, facts and historical knowledge | | agent | Current Agent agreements | Private Agent experience | | current_workspace | Unsupported | Project facts, decisions and procedures | Initial assembly or syncshot loads both Profiles and freezes them with Instruction, then the Agent decides whether to query knowledge. Identity and Instruction share one Agent system message, immediately followed by a separate Agent Profile system message. User Profile is another independent system message before Session information. Both Profiles belong to the frozen system snapshot, never enter user content, and do not define the current task. Canonical messages remain unchanged. Current requests and permissions take precedence. ## remember ```json {"target":"user","mode":"profile","content":"Before the main answer, remind me to speak English and briefly correct my expressions."} ``` | Parameter | Behavior | | --- | --- | | content | Required non-empty text | | target | user, agent, current_workspace | | mode | profile or knowledge; default knowledge | | topic | Knowledge topic; default inbox | | memory_type | fact, preference, decision, episode, procedure, document; default fact | | source | Optional knowledge evidence label | Profile appends immediately and returns profile_id. User writes require no login or resolve_user callback. Knowledge writes evidence and a wiki topic and returns memory_id and evidence_id. Default writes append deterministically rather than semantically merging. Only successful tool results establish that saving completed. ## Results and activation Use target, not scope, in remember payloads. Explicit persistent behavior changes should choose mode=profile; omitting mode still defaults to searchable knowledge. Results consistently include target, mode, status, activation and location, plus profile_id or memory_id/evidence_id. Profile returns status=saved, activation=new_session_or_syncshot and location=PROFILE.md. Knowledge returns status=created/updated, activation=on_recall and location=wiki. mode no longer doubles as an operation status. User/Agent knowledge management and status queries require no Workspace; project writes do. A full Profile management page is still pending. ## Document maintenance Both scopes use MemoryProfile. Appends have no hard size cap and are idempotent by source. Each Profile is checked once per local calendar day; documents above 2,000 visible Unicode characters are optimized with profile_model_id. Model-generated exact edits must have unique matches, non-overlap, unchanged version and retained source markers. The original is backed up. Valid agreements take precedence over the target length. Saving, editing and daily optimization update source files. New Sessions use them; existing Sessions keep the original system until explicit syncshot. Failure to read one scope does not suppress the other. SOUL.md remains independently managed Agent configuration. ## User center and host APIs - memory.user_read: returns the one local user's base information. - memory.user_update: updates display_name without changing user_id. - memory.profile_read/status: target=user, or target=agent plus agent_id. - memory.profile_edit: the scope plus version and old_text/new_text edits. - memory.profile_delete: the scope plus version; backs up and deletes the document. Read returns the version. Re-read after conflicts. A profile_id is not a knowledge ID accepted by ordinary read/revise/forget. Dedicated Desktop management UI is pending. ## Configuration and storage ```ts const memory_power = new MemoryPower({ platform_root_path: "/absolute/platform-root", profile_model_id: "configured-text-model", }); ``` No identity callback, decision model or enable flag is needed. A missing model affects daily optimization only. City must run for checks; restart after configuration changes. ```text /user/user.json /user/PROFILE.md /user/memory/wiki/ and evidence/ /agents//PROFILE.md /agents//memory/wiki/ and evidence/ /workspaces//wiki/ and evidence/ ``` Old multi-user directories and Agent wiki preferences are not migrated automatically. This is a single-owner local system: authorized channels share the local User, and the host continues to control channel authorization. Knowledge tools remain status, list, search, read, remember, digest, revise and forget. Search is deterministic text retrieval. Default revise appends correction instructions; explicit digest archives Session text. ### Shell Built-In Source: https://downcity.ai/en/powers-docs/builtins/shell/ Shell is a built-in Workspace capability from `@downcity/city`, not a Power. Shell owns its `SandboxProvider`, the persistent Sandbox created for its Workspace, and all Shell Sessions created inside it. City does not know which execution backend the Workspace uses. ```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: "/path/to/project", shell: new Shell({ sandbox_provider: new MicrosandboxProvider(), }), }); const city = new City({ workspaces: [workspace], }); const agent = new Agent({ id: "repo-helper" }); city.agents.add(agent); ``` Shell commands default to `target: "sandbox"`. Paths depend on the provider: Microsandbox mounts the project at `/workspace`; NativeSandboxProvider keeps the real host project path. Use `target: "host"` with a clear `reason` when host execution is required; the request must pass approval. NativeSandboxProvider uses Seatbelt on macOS and Bubblewrap on Linux. It permits host file reads except for protected paths and permits writes to the Workspace, runtime and platform temporary directories. A read-only grant blocks writes even inside a writable root. Protected paths block both reads and writes; on Linux their content is hidden behind empty mounts. This is not an isolated HOME or a filesystem read allowlist. Network access is configured separately and defaults to allowed. Before native command launch, credential-like environment variables (tokens, API keys, passwords, secrets and credential paths) and SSH agent variables are removed. Host code can grant specific names through `new NativeSandboxProvider({ allowed_credential_env: ["NPM_TOKEN"] })`; names match exactly. Downcity internal authentication tokens are always removed. PATH, HOME and non-credential build settings remain available. This filters inherited variables only: login scripts and readable host files may still supply credentials, and unrecognized variable names are not detected. Host code can explicitly authorize an SSH agent without exposing private key files: ```ts const sandbox_provider = new NativeSandboxProvider({ ssh: { socket_path: "/absolute/path/to/agent.sock", known_hosts_path: "/absolute/path/to/dedicated_known_hosts", }, }); ``` The paths must exist as a Unix socket and regular file, and network must be `allow`. Maintain a dedicated known_hosts file after verifying remote fingerprints; unknown hosts are not trusted automatically. The host-authorized socket replaces command-provided SSH_AUTH_SOCK. Without SSH authorization, this variable is removed even if included in the generic credential allowlist. Git receives a default SSH command that ignores user configuration, does not load private keys, disables agent forwarding and strictly verifies hosts. Direct ssh calls need their own corresponding options; the Git default is not an enforced policy for arbitrary commands. Authorization grants signing use of the agent identities, not repository-scoped access. The standard agent protocol does not export private keys. CLI/Desktop does not authorize SSH automatically. Desktop Workspace settings include an SSH identity section for an explicit socket and verified known_hosts records, with a native confirmation before applying. Authorization persists per Workspace; socket identity changes require authorization again. Changes interrupt tasks and rebuild the Workspace runtime. Reconstruction errors are reported and leave runtime access blocked until a retry. CLI has no authorization management command yet, but Workspace creation validates and uses saved records. Exact authorized endpoints can reopen within protected directories. `sandbox.get` reports the known_hosts exception through `allowed_read_paths`; path explanations allow that exact file while keeping sibling files protected. A socket grants connection capability, not file-read access, and the path explanation API does not judge socket connections. Use `context.sandbox.get` to inspect the effective boundary. `writable_roots` lists broad write grants; `denied_write_paths` takes precedence and includes read-only and protected subtrees. `denied_read_paths` lists protected content. `context.sandbox.explain_path` checks write exclusions before writable roots. Native policies resolve existing path aliases when created; path explanations remain lexical. Native provider self-checks validate allowed writes, outside writes, read-only grants, protected content and child-process inheritance using disposable probe files. A local TCP service verifies connectivity under the allow policy and isolation under the deny policy without internet access. This checks TCP to host loopback, not every protocol. Probe timeouts, launch errors and self-check exceptions are failures. Linux execution also fails when a missing restricted path beneath a writable root cannot be safely enforced. Chat Sessions in one Workspace share its sandbox resources. Native execution uses the host toolchain and HOME; it does not provide separate machines or temporary directories per Workspace. A `shell_id` identifies a Shell Session and is never interchangeable with a Chat `session_id`. Before the first sandbox command, Shell waits for `SandboxProvider.check()`. Concurrent commands share one check. A failed or throwing check blocks sandbox launches; it never triggers automatic host execution. The result remains cached until a successful explicit `reset_sandbox()`, after which the next command checks again. Providers without reset require a new Shell/Workspace to retry. Reset rejects new commands while in progress; a failed reset keeps sandbox execution blocked until reset succeeds. Disposal rejects new work and commands waiting for readiness. Host execution follows its separate approval flow. Closing City or Workspace preserves persistent files. Native execution has no persistent VM to stop; Shell owns command lifecycle management. See [Shell and Sandbox](/en/agent-sdk-docs/local-agent/shell-reference) for lifecycle, environment, and approval details. ### NotesPower Scenario Source: https://downcity.ai/en/powers-docs/examples/notes-power/ ```ts import { Power, type PowerActions, type PowerContext, } from "@downcity/city/power"; class NotesPower extends Power { readonly name = "notes"; readonly title = "Project Notes"; readonly description = "Describe the project notes folder."; system(context: PowerContext) { return `Project notes folder: ${context.workspace.path}/.notes`; } readonly actions: PowerActions = { status: { execute: async ({ context }) => ({ success: true, data: { workspace_path: context.workspace.path, storage_path: context.storage.path, }, }), }, }; } const notes_power = new NotesPower(); ``` The host provides `notes_power` to City, which exposes it to every Agent. After entering a Workspace, host code may call `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action()` explicitly. Sessions collect System content and Hooks from the same City execution view. This Power owns no long-lived resources, so it needs no Lifecycle. Use `context.workspace.files` for user project files and `context.storage.files` for private Power state. ### ReviewGuardPower Scenario Source: https://downcity.ai/en/powers-docs/examples/review-guard-power/ ## Scenario You want to enforce one rule in a runtime chain: - if a request does not carry `reviewed = true` - block the rest of the flow That is the most natural kind of `guard` scenario. ## Example ```ts import { Power, type PowerHooks } from "@downcity/city/power"; class ReviewGuardPower extends Power { readonly name = "review_guard"; readonly title = "Review Guard"; readonly description = "Blocks requests that were not reviewed."; readonly hooks: PowerHooks = { guard: { "review.require_checked": [ async ({ value }) => { const body = value as { reviewed?: unknown }; if (body.reviewed !== true) { throw new Error("review is required before continuing"); } }, ], }, }; } // City has registered review_guard and bound it to entry's Agent/Workspace. await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", { reviewed: true, }); ``` ## Why this is not a pipeline Because you are not trying to rewrite a value. You are trying to: - allow the flow - or stop it immediately That is exactly what guards are for. ## Related docs - [Power hooks and resolve](/en/agent-sdk-docs/powers/power-hooks) - [Custom power](/en/agent-sdk-docs/powers/custom-power) ### MessageAugmentPower Scenario Source: https://downcity.ai/en/powers-docs/examples/message-augment-power/ ## Scenario You want to enrich a message before it enters the main execution path: - add the current page title - add a source marker This kind of pre-chain value enrichment is a strong fit for `pipeline`. ## Example ```ts import { Power, type PowerJsonObject, type PowerHooks, } from "@downcity/city/power"; class MessageAugmentPower extends Power { readonly name = "message_augment"; readonly title = "Message Augment"; readonly description = "Adds extra fields to inbound message payloads."; readonly hooks: PowerHooks = { pipeline: { "chat.enrich_message": [ async ({ value }) => { const body = value as PowerJsonObject; return { ...body, source: body.source || "web", pageTitle: body.pageTitle || "Untitled Page", }; }, ], }, }; } // City has registered message_augment and bound it to entry's Agent/Workspace. const message = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", { text: "Please summarize this page", }); ``` ## What this scenario is meant to teach - a pipeline receives one value and returns a new value - several powers can extend the same chain in sequence ## Related docs - [Power hooks and resolve](/en/agent-sdk-docs/powers/power-hooks) - [Power overview](/en/agent-sdk-docs/powers/overview) ### AuditEffectPower Scenario Source: https://downcity.ai/en/powers-docs/examples/audit-effect-power/ ## Scenario You want to record an event: - a message arrived - an action was triggered but you do not want to: - transform the value - block the flow That is a clean `effect` use case. ## Example ```ts import { Power, type PowerHooks } from "@downcity/city/power"; class AuditEffectPower extends Power { readonly name = "audit_effect"; readonly title = "Audit Effect"; readonly description = "Writes one audit line for every observed event."; readonly hooks: PowerHooks = { effect: { "audit.observe": [ async ({ value, power }) => { console.log(`[${power}] observed`, value); }, ], }, }; } // City has registered audit_effect and bound it to entry's Agent/Workspace. await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", { event: "incoming_message", session_id: "sess_001", }); ``` ## Why this is not a pipeline Because you do not care about returning a new value. The side effect is the whole point. ## Related docs - [Power hooks and resolve](/en/agent-sdk-docs/powers/power-hooks) ### RoleResolverPower Scenario Source: https://downcity.ai/en/powers-docs/examples/role-resolver-power/ ## Scenario You want one runtime path to ask: - "what is this user's current role?" That is not a collaborative transformation problem. It is a single-answer resolution problem. That makes it a classic `resolve` case. ## Example ```ts import { Power, type PowerResolveHook, } from "@downcity/city/power"; class RoleResolverPower extends Power { readonly name = "role_resolver"; readonly title = "Role Resolver"; readonly description = "Resolves one final role for a user."; private readonly resolveRole: PowerResolveHook = 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"; return "member"; }; readonly resolves = { "auth.resolve_role": this.resolveRole, }; } // City has registered role_resolver and bound it to entry's Agent/Workspace. const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>( "auth.resolve_role", { userId: "owner" }, ); ``` ## Why there cannot be two resolvers Because the semantic question is not: - "let several powers each add something" It is: - "who is the final authority for this answer?" That is why duplicate resolve registration fails in the current implementation. ## Related docs - [Power hooks and resolve](/en/agent-sdk-docs/powers/power-hooks) - [Power availability](/en/agent-sdk-docs/powers/power-availability) ### SnapshotHttpPower Scenario Source: https://downcity.ai/en/powers-docs/examples/snapshot-http-power/ ## Scenario You want one power to provide a read-only snapshot endpoint: - `GET /api/notes/snapshot` That is a clean fit for `power.http.server`. ## Current boundary This capability belongs to the full runtime HTTP assembly path. That means: - `power.http.server` is not the same kind of explicit call as `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action(...)` - Downcity exposes HTTP and automatically mounts power HTTP routes for the running Agent - if you need this, think of it as a fuller host/runtime integration capability ### Power Runtime Composition Source: https://downcity.ai/en/powers-docs/examples/power-runtime-composition/ One of the most important design questions is: Should this logic live in power actions or hooks, or in lifecycle-owned power runtime? ## Good thin-power cases - explicit user-facing actions - hook participation - system prompt injection - small runtime helper protocols - small runtime HTTP surfaces ## Good lifecycle-owned runtime cases - queue workers - cron engines - durable session pools - long-lived connections - in-memory runtime state that must be cleaned up on stop ## A practical rule If the thing must start, stop, recover, or be polled over time, it probably needs lifecycle-owned power runtime, not just one thin action. ## Real built-in references - `messenger`: queue worker owned by the power instance - `task`: cron engine owned by the power instance - `schedule`: lifecycle-only infrastructure power - `shell`: long-lived shell sessions owned by the power instance ## Related docs - [Power Design Patterns](/en/agent-sdk-docs/powers/design-patterns) - [Power Lifecycle](/en/agent-sdk-docs/powers/lifecycle) - [Built-ins Overview](/en/powers-docs/builtins/overview) ### Agent Powers Docs Source: https://downcity.ai/zh/powers-docs/ 这一组文档专门讲“具体 Power 本身”。 当你想看 `web`、`skill`、`messenger`、`task` 这类具体 Power 时,应该从这里读。 如果你想先理解 Power 的设计模式和概念,请先看 [Power Overview](/zh/agent-sdk-docs/powers/overview)。 ## 这里主要覆盖什么 - 当前代码里到底有哪些 built-in Power - 哪些内建 Power class 从 `@downcity/powers` 公开导出 - 哪些 Power 更偏平台内部或基础设施 - 每个 Power 的职责边界 - 每个 Power 的 lifecycle、availability、config 和 action 形状 ## 推荐阅读路径 - 先看 [Built-ins Overview](/zh/powers-docs/builtins/overview) - 再进入你关心的具体 Power 页面 - 想看更场景化的写法,再去 [Examples](/zh/powers-docs/examples) ## 最好先记住的心智模型 这里有两个不同问题: 1. Power 系统本身是怎么工作的 2. 某一个具体 Power 到底负责什么 这一组文档主要回答第二个问题。 ## 继续阅读 - [Built-ins Overview](/zh/powers-docs/builtins/overview) - [Examples](/zh/powers-docs/examples) - [Agent SDK 里的 Power 概念文档](/zh/agent-sdk-docs/powers/overview) ### Built-ins Overview Source: https://downcity.ai/zh/powers-docs/builtins/overview/ 当前代码里的内建 Power 能力包括: - `skill` - `web` - `sound` - `messenger` - `schedule` - `task` - `memory` - `shell` ## 包导入路径 每个面向 SDK 的内建 Power 都有独立包子路径: | 内建能力 | 导入路径 | | --- | --- | | `messenger` | `@downcity/powers/messenger` | | `memory` | `@downcity/powers/memory` | | `skill` | `@downcity/powers/skill` | | `task` | `@downcity/powers/task` | | `web` | `@downcity/powers/web` | `image` 与 `sound` 属于 City capability,不是 `@downcity/powers` 的子路径, 通过 City 配置而不是从包子路径导入。 可以使用 `create_builtin_power_registrations()` 创建默认集合,也可以按 City 的实际需要导入并 实例化指定 Power。选择发生在 City,不发生在单个 Agent。 ## 这里有两个重要分组 ### 面向 SDK 用户的公开能力型 built-ins 这组通常最值得普通开发者先读: - `skill` - `web` - `sound` - `messenger` 其中 `sound` 需要通过 constructor 注入 FED 模型目录、ASR 与 TTS 能力。内建 Power 统一从各自包子路径导入并显式装配。 它们最适合理解这些 Power 形状: - action power - hook / resolve power - system text power - 一般 SDK 用户也能直接理解的 lifecycle Power ### 平台内建或基础设施型 built-ins 这组也是 built-in,但更偏平台内部集成: - `schedule` - `task` - `memory` - `shell` 这类 Power 更适合拿来理解平台组装方式,而不是默认假设所有 SDK 用户都会手工挂到本地 `Agent` 上。 ## 推荐怎么读 - 如果你关心用户能力型 Power:先读 `skill`、`web`、`sound`、`messenger` - 如果你关心平台规则层:读 `messenger` 的内置授权能力 - 如果你关心基础设施模式:读 `schedule`、`task`、`memory`、`shell` `skill` 统一提供模型动作 `list`、`lookup`、`servers`、`tools`、`call`。Sidebar 列出 Skills 与 MCP 资源,**Agent Profile → Skills** 管理技能关联与 MCP 分配。全局 MCP Server 定义和凭据保留在原有存储目录。参见 [Skills](/zh/powers-docs/builtins/skill) 与 [MCP Server](/zh/docs/power/mcp)。 ## 职责地图 | Power | 主要职责 | 主要形状 | | --- | --- | --- | | `skill` | 技能发现与内容读取、MCP 工具发现与调用、按 Agent 分配能力 | lifecycle + actions + system | | `web` | 联网方法论注入与依赖准备 | action + system | | `sound` | 从 FED 发现语音模型,统一提供 ASR、TTS 与入站自动转写 | action + pipeline + system | | `messenger` | 渠道 runtime、队列 worker、messenger 授权与角色解析 | lifecycle + actions + hooks + resolves + system | | `schedule` | Power Action 调度基础设施 | lifecycle-only infrastructure | | `task` | task 调度与 cron 执行 | lifecycle + actions + system | | `memory` | Provider 可替换的长期记忆与召回 | lifecycle + actions + system | | `shell` | shell session runtime | lifecycle + actions | ## 一个关键区别 不要把“built-in”理解成“它们的公开性完全一样”。 这些内建 Power 其实分成三类: - 公开能力型 Power - 平台集成型 Power - 基础设施型 Power 也正因为如此,Power 的概念页保留在 Agent SDK Docs,而具体 Power 手册单独放在这里。 ## 相关概念文档 - [Power Overview](/zh/agent-sdk-docs/powers/overview) - [Power Design Patterns](/zh/agent-sdk-docs/powers/design-patterns) - [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle) - [Power Availability](/zh/agent-sdk-docs/powers/power-availability) ### Presentation Source: https://downcity.ai/zh/powers-docs/builtins/presentation/ Presentation 让 Agent 在 Desktop 回复中使用图表、比较卡片、表格和参数控件。你可以直接说“用可调利率的图表解释复利”或“把这些方案用卡片和表格比较”。 文字与模块按产生顺序穿插显示;对应模块成功发布后不重复显示成功工具活动。执行中、失败和待响应状态仍可见。 模块沿用 Desktop 的字体、字号、主题和控件圆角,采用单层细边框、突出的关键指标、细轨道滑块和紧凑参数区。普通卡片使用轻量透明布局,表格采用紧凑行距,图表弱化辅助网格并保留系列辨识度。新生成模块优先按“标题、指标、图表、参数、操作”组织,减少重复标题和嵌套卡片。 模块内的文字使用与回复正文相同的 Markdown 方言:表格渲染为真正的表格,`mermaid` 围栏渲染为可缩放、可全屏的图表,公式走 KaTeX,本地图片沿用受控文件协议。 ## 生成与操作 支持本地参数、筛选、重置,以及点击按钮把当前参数和问题提交给 Agent。调整控件不产生模型请求,明确提交才会继续对话。最近视图状态保存在 Desktop 本地,不跨设备同步。 生成过程中,消息尾部的模块可以按语句逐步显示,控件在完整校验通过前禁用。这依赖模型服务发送工具参数增量;不支持或无法保证顺序时,模块在生成完成后显示。 模块发布成功后,本轮回答可以正常结束,界面仍可操作,无需等待你点击按钮。需要多个不同模块时可以依次发布;成功内容不需要反复生成来确认。 ## 动作 `presentation.show` 使用 `content` 和 `fallback_text`。content 是 OpenUI Lang,采用位置参数;第一行声明 root,随后定义引用的组件与数据。 ```json { "content": "root = Stack([header, chart, buttons])\nheader = CardHeader(\"增长比较\")\nchart = LineChart([\"2024\", \"2025\"], [Series(\"收入\", [10, 20])])\nbuttons = Buttons([Button(\"解释结果\", @ToAssistant(\"解释图表结果\"))])\n", "fallback_text": "收入从 2024 年的 10 增长到 2025 年的 20。" } ``` 当前开放 74 个组件(包括配套 Item):产品/概念/指标卡片、键值列表、内容列表、步骤、轮播、图标和图标按钮、Markdown、代码、图片与图库,以及已有布局、表格、图表和指标。输入支持日期、单选、多选、开关、Chips 和选项卡片,字段随表单提交给当前对话。`$变量` 保存本地状态,`@Set` 修改、`@Reset` 重置,`@ToAssistant` 提交问题。 复利可使用 `@CompoundValues` 与 `@YearLabels`:本金 0–10¹²,利率为 -1–1 小数,年限为 1–100 整数。 ## 限制与回退 图片可使用真实 http(s) 地址、内联位图或 Desktop 已授权的本地绝对路径;本地文件复用受控文件协议,不能凭路径绕过权限。相对路径需先解析为绝对路径,图片加载失败保留文字。没有真实图片时应使用纯文字卡片。代码只展示、不执行。 不开放远程 Query / Mutation、任意业务调用或跳转;EditableTable、Modal 与新增图表类型暂未开放。内容最多 240 KiB、200 条语句、32 层嵌套,提交参数最多 16 KiB。 缺少 Renderer、协议版本不支持或组件失败时显示摘要。旧 JSON Spec 历史也使用文字回退。此版本基于 MIT 许可的 OpenUI,不要求使用其托管模型服务。 ### Skills Source: https://downcity.ai/zh/powers-docs/builtins/skill/ `skill` 在同一个 Power 中管理 Skills 与 MCP 资源。每个 Agent 拥有自己的技能目录,该目录就是它可用技能的权威集合。这个 Power 负责发现目录内的技能、读取 `SKILL.md`、把清单注入 system prompt,并提供明确分配给该 Agent 的 MCP Server 工具。 技能存放在 `~/.downcity/agents//skills`。每个条目是一个包含 `SKILL.md` 的目录,来源要么是复制进 Agent 目录的安装副本,要么是指向外部目录的软链接。 该目录位于 Agent 自己的数据根下,因此删除 Agent 会一并删除它的技能。该目录已授权给 Agent 的 shell,所以 Agent 可以自己安装与关联技能。根路径由容器提供(`PowerContext.data_root_path` / `PowerLifecycleContext.data_root_path`),不来自构造参数。 ## 作用域 Agent 自己的目录是默认来源,也是权威来源。另有两条可按需读取的额外来源: - `project` 读取 `/.agents/skills` - `home` 读取 `~/.agents/skills` - `all` 返回 Agent 目录、project、home 的并集 同一个技能 ID 出现在多个来源时,Agent 目录优先,其次 project,最后 home。 `list` 与 `lookup` 接受相同的 `scope` 参数,且都默认只看 Agent 目录。system prompt 里的清单也只列 Agent 目录,因此模型看到的技能名可以直接使用,不需要额外传 scope。 ## Actions | Action | Payload | 作用 | | --- | --- | --- | | `list` | `{ scope?: "agent" \| "project" \| "home" \| "all" }` | 列出指定作用域的技能,默认只看 Agent 目录 | | `lookup` | `{ name: string, scope?: ... }` | 读取技能的 `SKILL.md` | | `servers` | `{}` | 列出分配给当前 Agent 的 MCP Server 及其状态,包括已停用或定义无效的 Server | | `tools` | `{ server_id: string }` | 读取已分配 MCP Server 的工具名与输入 Schema | | `call` | `{ server_id: string, tool_name: string, arguments: object }` | 调用已分配 MCP Server 的工具 | `lookup` 把技能正文作为标准 context 消息返回,正文会在下一步进入模型上下文,不写入任何会话状态。 模型侧没有 `find` 与 `install`。安装与关联要么由 Agent 自己通过 shell 完成,要么在 Desktop 上完成:`skills` CLI 无法直接装进 Agent 技能目录,而且「往技能目录里放什么」需要有人做判断。 Agent 自己安装分两步:在临时目录里跑 `skills` CLI,再把得到的技能拷进自己的目录。system prompt 里带了完整命令,包括让 `npx` 在沙箱内可用的 `npm_config_cache` 覆盖。 用 `scope` 读取 `project` 或 `home` 并不会让那些技能变得可用。用户目录或 Workspace 里的技能,只有被复制或关联进 Agent 目录之后才能使用,`list` 默认返回的也正是这些条目。 列出 Agent 的技能: ```ts const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({ power: "skill", action: "list", payload: {}, }); ``` 读取技能: ```ts const result = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action({ power: "skill", action: "lookup", payload: { name: "web-access", }, }); ``` 模型在 Session 中使用相同协议: ```ts skill({ action: "lookup", args: { name: "web-access", }, }); ``` ## 配置 Skill 的 Config 面板管理全局 MCP Server 定义。已有定义与 OAuth 凭据仍保存在 `~/.downcity/powers/mcp/`,界面合并不会移动这些文件。Server 文件格式与连接行为参见 [MCP 配置](/zh/docs/power/mcp)。 技能发现的作用域由 Agent 目录与 `scope` 参数决定。通过 `ignore` 过滤技能: ```ts const skill_power = new SkillPower({ ignore: ["legacy-skill", /^internal-/], }); ``` - 字符串会同时匹配技能 ID 与名称。 - `RegExp` 用于批量过滤。 - 判断函数返回 `true` 表示排除该技能。 ## Desktop 统一的 Skill Sidebar 按 **Skills** 与 **MCP** 列出具体资源,不按 Agent 分组。选择 Skill 查看内容、来源路径与使用它的 Agent,选择 MCP Server 查看连接状态、工具与错误。不同目录下同 ID 的 Skill 保持为独立资源,Sidebar 会显示其来源;指向同一实际目录的链接共享一个资源及其 Agent 使用信息。 在 **Agent Profile → Skills** 中,可以从用户目录添加技能、移除已分配技能,并为当前 Agent 启用 MCP Server。全局启用 Server 并不会授予 Agent 访问权限。分配关系以 Server ID 的 JSON 数组保存在 `~/.downcity/agents//mcp-servers.json`;文件不存在时没有 MCP 访问权限。 宿主动作 `skill.mcp.assign` 与 `skill.mcp.unassign` 接受 `{ agent_id, server_id }`,通过 Power action gateway 管理分配关系,不属于模型动作。模型通过 `skill` 的 `servers`、`tools`、`call` 使用 MCP,不能访问未分配的 Server。 可关联来源只有用户目录。Workspace 的 `.agents/skills` 不进界面:一个 Skill 想给 Agent 用,先放进用户目录即可。 移除关联技能只删除软链接,用户目录不受影响。操作不会越出 Agent 目录。 ## 相关文档 - [内建 Power 总览](/zh/powers-docs/builtins/overview) - [Power Actions](/zh/agent-sdk-docs/powers/power-actions) - [Power System](/zh/agent-sdk-docs/powers/power-system) ### web Power Source: https://downcity.ai/zh/powers-docs/builtins/web/ `web` 由 WebPower 自己持有搜索、网页读取和浏览器 Provider,不依赖 `context.agent.web`。Desktop 安装后无需手工启动浏览器:静态网页读取和本地 Chrome 浏览器默认可用,搜索在配置 API Key 后启用。 ## Desktop 配置 进入「Powers → Web」。每个能力就地配置:在侧栏能力行上用下拉选 Provider,再到 Mainview 里改这个能力自己的参数。 - 搜索:`Auto`、`Tavily`、`Exa`、`DeepSeek` 或关闭。Auto 按 Tavily → Exa → DeepSeek 的顺序选择首个已配置 Key 的 Provider。 - 网页读取:默认 `Built-in Fetch`,也可以选择 Firecrawl 或 DeepSeek。 - 浏览器:默认 `Local Chrome`,也可以切换为远程 CDP 或关闭。 - 高级:操作超时、观察字符上限、浏览器默认 URL 与可选的 Chrome 路径。 API Key 可以直接在能力视图里写入。已保存的 Key 只显示“已配置”,不会回显明文; 空输入会保留旧 Key,只有点击“清除”并保存才会删除。尚未配置的 Key 旁边有一个 「去申请」链接,直接打开服务商控制台,不用自己去搜。Key 当前保存在 Downcity 本地 Power 配置文件中,尚未使用系统钥匙串。 也可以在「设置 → Global Env」写入: ```dotenv TAVILY_API_KEY=... EXA_API_KEY=... DEEPSEEK_API_KEY=... FIRECRAWL_API_KEY=... ``` Web 配置中的 Key 优先于 Global Env。每个能力区都提供测试按钮;测试会先保存当前 配置,再通过一个真实 Agent 和 Workspace 执行最小调用。 ## Desktop 工作区 Web 在一级导航条贡献独立入口,提供 Sidebar 与 Mainview。它不声明独立的 Config 面板:每个能力就地配置。 - **Sidebar**:「能力」分区下每个能力是一个分支,右侧用 tag 表达当前模式(`Auto`、`Fetch`、`已关闭` 或 `待配置`),展开后列出它的 Provider;每个 Provider 行右侧是一个状态点,绿点可用、黄点还缺凭据。分区末尾是「通用」。 - **Mainview**:一屏只管一个能力——开关、工具栏说明里的实际 Provider,以及该能力自己的参数。浏览器能力还会列出 Provider 当前拥有的会话。API Key 不会被返回,界面只显示是否已配置。 - **通用**:三个能力共用的参数(操作超时、观察字符上限),以及配置文件的路径与打开按钮。 - **高级**:承载跨能力的参数——操作超时、观察字符上限、浏览器默认 URL 与可选的 Chrome 路径。 搜索、网页读取与浏览器各自有开关。关闭一个能力与「选哪一家 Provider」不是一回事,因此 `关闭` 不列在 Provider 里。 Web 配置是全局的:一份配置对所有 Agent 与 Workspace 生效,因此工作区不要求选择执行范围。保存是字段级的:选中 Provider 只提交自己那一个字段,不会覆盖页面上其它尚未保存的输入。 创建、观察与关闭浏览器会话仍由 Agent 的 action 完成,页面不会自己持有浏览器。 ## 默认行为 ```ts import { WebPower } from "@downcity/powers/web"; const web = new WebPower(); ``` - `open` 使用内建 Fetch,只读取公网 HTTP(S) 页面,不执行页面脚本。 - 浏览器自动发现 Google Chrome、Chromium 或 Microsoft Edge,并使用 Power 私有的持久 profile。 - `search` 在找到 Tavily、Exa 或 DeepSeek Key 后启用;没有 Key 时只禁用搜索,不影响其他能力。 本地浏览器进程和它创建的页面由 WebPower 负责关闭。持久 profile 保留用户主动建立的 登录状态。若需要云浏览器,将 Browser Provider 切换为 `Remote CDP` 并填写 HTTP 或 WebSocket endpoint。 ## 浏览器代理 Local Chrome 会应用 Desktop 当前解析出的网络代理(Desktop 显式设置 → 系统代理 → Global Env)。 SDK / CLI 按 `DOWNCITY_PROXY_URL`、`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 的顺序读取代理, 也支持通用变量的小写形式。未配置时由 Chrome 遵循系统网络设置。 本地浏览器支持不带用户名和密码的 HTTP、HTTPS、SOCKS4 与 SOCKS5 代理地址。 代理绕过规则读取 `DOWNCITY_NO_PROXY`、`NO_PROXY`、`no_proxy`,本地回环地址始终直连。 修改代理后,已有浏览器会话继续保留。先通过 `browser_list_sessions` 查找会话,再用 `browser_close_session` 关闭;下一次创建会话时,自有浏览器会自动重启并应用新配置。 若提示正在运行的 Web Power 浏览器代理不同或未知,请先关闭该 Chrome 窗口再重试。 Remote CDP 的代理由远端浏览器拥有者配置。 ## SDK 固定配置 构造配置的优先级高于 City/Desktop 保存的配置: ```ts const web = new WebPower({ config: { search_provider: "tavily", tavily_api_key: process.env.TAVILY_API_KEY, document_provider: "firecrawl", firecrawl_api_key: process.env.FIRECRAWL_API_KEY, browser_provider: "cdp", cdp_url: "https://browser.example.com/cdp", }, }); ``` 宿主仍可注入自定义 `search_provider`、`document_provider` 或 `browser_provider_factory`。注入对象的生命周期所有权转移给 WebPower。 ## DeepSeek 搜索与网页读取 在搜索和网页读取中分别选择 `DeepSeek`,共用一个 DeepSeek API Key;在任一能力页保存或清除 Key,都会同时影响这两种能力。也可以通过 SDK 固定配置: ```ts const web = new WebPower({ config: { search_provider: "deepseek", document_provider: "deepseek", deepseek_api_key: process.env.DEEPSEEK_API_KEY, }, }); ``` - `search` 使用 DeepSeek Search API,优先返回适合模型阅读的长摘要与原始排序分数;`limit` 最多返回 50 条结果。 - 支持官方 `site:`、`-site:`、`lang:`、`after:`、`before:` 等查询语法。`domains` 会转换为 `site:` 过滤;国际化域名请使用 punycode。 - 查询连同域名过滤最多 128 个 Unicode 字符,超过上限会明确报错,请缩短关键词或减少域名。 - `open` 使用 DeepSeek Fetch API 返回页面标题和 Markdown,遵守 `max_chars` 正文上限。 - 两个接口各自按账号限制为 5 QPS,Fetch 另有目标域名限速;超过限制会返回 HTTP 429。 - HTTP `408`、`429`、`5xx` 最多自动重试两次。优先按 `Retry-After` 等待,否则使用带随机抖动的指数退避;所有尝试和等待共用配置的操作超时。服务端要求的等待超出剩余时间时直接返回最后一次 HTTP 错误。其它 `4xx`、网络异常和无效响应不会自动重试。 - 官方标价为 Search / Fetch 各每 1,000 次成功请求 5 元,失败、搜索无结果或未返回正文不计费。点击测试也会发出实际 API 请求。 接口与最新价格见 [DeepSeek 官方指南](https://api-docs.deepseek.com/zh-cn/guides/search)。 ## Actions | Action | 用途 | |---|---| | `status` | 读取当前执行范围实际生效的搜索、文档与浏览器 Provider | | `search` | 使用 Tavily、Exa、DeepSeek 或注入的搜索 Provider | | `open` | 使用安全 Fetch、Firecrawl、DeepSeek 或注入的文档 Provider | | `browser_create_session` | 创建浏览器 Session | | `browser_observe` | 读取 URL、标题、Accessibility Snapshot、可见文本、元素 ref 和可选截图 | | `browser_act` | 使用 observation generation + ref 执行确定性动作 | | `browser_semantic_act` | 通过可选语义 Adapter 执行自然语言动作 | | `browser_extract` | 按 CSS selector 或 body 抽取文本 | | `browser_semantic_extract` | 通过可选语义 Adapter 抽取内容 | | `browser_close_session` | 关闭 Session 并释放页面 | | `browser_list_sessions` | 列出 Provider 当前仍拥有的浏览器 Session | 先观察,再使用该代观察中的 ref: ```ts const observed = await web.actions.browser_observe.execute({ context, input: { session_id }, }); await web.actions.browser_act.execute({ context, input: { session_id, action: { type: "click", ref: observed.data.elements[0].ref, observation_generation: observed.data.observation_generation, }, }, }); ``` 元素 ref 只在生成它的 observation generation 内有效。动作返回新观察后必须使用新的 ref;CSS selector 仅保留给显式的确定性自动化。 ## 网络与安全边界 - Fetch 只允许公网 HTTP(S),每次重定向都重新检查目标并阻止本机、私网和链路本地地址。 - Tavily、Exa、Firecrawl 与 DeepSeek 的官方 API 请求禁止跟随重定向,避免凭据被转发到其它地址。 - 网络请求限制超时、重定向次数和响应大小。 - Provider 错误不返回 API Key、认证请求头或完整错误响应正文。 - 提交、发送、购买、上传和删除等副作用仍必须经过宿主审批。 - 页面内容始终视为不可信输入,不能覆盖 Agent 或宿主指令。 ## 相关文档 - [内建 Power 总览](/zh/powers-docs/builtins/overview) - [Power Actions](/zh/agent-sdk-docs/powers/power-actions) - [Power System](/zh/agent-sdk-docs/powers/power-system) ### sound Power Source: https://downcity.ai/zh/powers-docs/builtins/sound/ `sound` 是统一的语音能力 Power。它从 FED 模型目录发现支持 `asr` 或 `tts` 的模型,并通过 City AI Service 完成真实调用。 `sound` 不下载或运行本地语音模型,也不依赖 Python、Whisper、Piper 等本地推理环境。本地音频文件只会被读取并转换成 data URL,然后发送给 FED。 ## 配置 ```ts { default_asr_model: "asr-model-id", default_tts_model: "tts-model-id", } ``` ## Actions ### `models` 列出 FED 中支持语音能力的模型: ```ts ai({ action: "sound.models", args: { capability: "asr" }, }); ``` `capability` 可为 `asr` 或 `tts`。不传时返回支持任意一种语音能力的模型,模型的 `modalities` 字段说明其具体能力。 ### `asr` 使用 FED ASR 模型转写音频: ```ts ai({ action: "sound.asr", args: { model: "asr-model-id", audio_path: "./input.wav", }, }); ``` 音频来源必须提供以下一种: - `audio_path`:本地绝对路径或相对 Agent 项目根目录的路径 - `url`:远程音频 URL - `data_url`:data URL 音频内容 结果采用与 AI SDK `TranscriptionResult` 对齐的可序列化核心字段: ```ts { text: string; segments?: Array<{ text: string; startSecond: number; endSecond: number; }>; language?: string; durationInSeconds?: number; } ``` ### 302.AI GLM-ASR 使用本地 Federation 的 302.AI 通道时,管理员需先配置 `AI302_API_KEY` 并重启服务。随后可通过 `sound.models` 查询模型,并指定 `model: "302-glm-asr-2512"` 调用 `sound.asr`。 该模型支持 WAV/MP3,每次最多 30 秒、25 MB。本地音频直接以 Base64 发送给 302.AI,无需公开文件 URL。长录音需先分段,其他格式需先转换。当前提供单段转写,不支持持续音频流,也不返回说话人或字级时间戳。 ### `tts` 使用 FED TTS 模型生成语音: ```ts ai({ action: "sound.tts", args: { model: "tts-model-id", text: "欢迎回来", voice: "alloy", format: "mp3", }, }); ``` TTS 返回 AI SDK `UIMessage`。音频使用 `file` part 表示,Agent 会统一把资源保存到项目资源目录并附加到 assistant 消息。 ## 默认模型 可以分别配置默认 ASR 与 TTS 模型: ```ts { default_asr_model: "asr-model-id", default_tts_model: "tts-model-id", } ``` 未配置默认模型且 action 未传 `model` 时,调用会失败并提示先读取 `sound.models`,不会隐式选择模型。 ## 自动转写 chat 语音 `auto_asr: true` 会在 chat 入站阶段自动转写 `voice` / `audio` 附件。自动路径无法交互选择模型,因此必须同时配置 `default_asr_model`: ```ts { auto_asr: true, default_asr_model: "asr-model-id", } ``` 转写文本会作为 `...` 追加到用户正文。单个附件转写失败不会阻断 chat 主链路。 Sound 的配置由 City 按 Agent 投影到 `PowerContext.config`;AI 服务来自 `PowerContext.city.embassy.user.ai`,不由 Agent 持有。 ### Messenger Power Source: https://downcity.ai/zh/powers-docs/builtins/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 中写入;已保存的密钥不会回显,编辑时留空表示保留原值。 ## 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(`origin.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,避免恢复时重复建单。 所有 Channel Turn 的最终回复统一通过 `session.turn_committed` 完成事件进入 Outbox,包括子 Work Session 完成后唤醒原 Channel 的后续轮次。后台结果继承原调用轮次的消息和 Thread 路由;期间收到的新消息不会覆盖它。同一完成事件对一个 Conversation 只建一条 Outbox 记录,合并输入不会重复回复。失败轮次发送简短失败通知,停止轮次不自动发送。 完成事件先写入 Agent 私有日志,callback 失败独立重试。自定义 SDK 宿主应在全部 Agent、Power 和项目资源装配完成后调用 `agent.resume_work()`,恢复未投递事件。详见[完成 callback](/zh/agent-sdk-docs/powers/power-hooks)。 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) ### schedule Power Source: https://downcity.ai/zh/powers-docs/builtins/schedule/ `schedule` 是延迟 Power Action 的基础设施概念。 它负责轮询调度任务,并把到点的 Power Action 重新派发执行。当前用户侧 CLI 里,它不是一个可以 start、stop 或执行业务 action 的独立 Power。 ## 主要形状 - 持久化 action job - runtime 轮询与恢复 - 没有业务 action ## 它主要解决什么问题 如果你想理解这些问题,应该看 action scheduling: - 为什么调度能力归属于 Power lifecycle,而不是 agent 顶层壳 - 延迟/定时 Power Action 是怎么被重新调用的 - 为什么它属于 Power runtime,而不是顶层 CLI 命令 ## 用户怎么使用 Action schedule 当前是 Power runtime 内部基础设施,不提供独立 CLI 管理命令。如果要做用户可管理、可复用的自动化,使用 `task` Power。 ## 关键语义 - 它主要轮询 `PowerScheduleStore` - 启动恢复时会把中断的 running jobs 重置回 pending - 它是核心基础设施模式,不是普通用户业务型 Power ## 公开性说明 action scheduling 属于平台基础设施,不是典型公开 SDK 能力 Power。 ## 相关文档 - [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle) - [Built-ins Overview](/zh/powers-docs/builtins/overview) ### task Power Source: https://downcity.ai/zh/powers-docs/builtins/task/ `task` 用同一套任务记录事情、执行工作并安排重复触发。TaskPower 只负责领域能力,Desktop App 原生提供收件箱、待办和日历入口;Agent 可以在聊天中创建和处理同一份任务。 ## 任务身份 每件事拥有稳定 `task_id`。标题可以修改,也允许同名。`create` 返回 ID,`list` 返回任务 ID 和工作属性;后续操作优先使用 ID。 现有动作的定位参数名为 `title`,它可以接受稳定任务 ID,也可以接受唯一标题。同名标题无法确定目标时会明确报错。 ## 常用动作 | 动作 | 用途 | | --- | --- | | `generate` | 内部模型将自然语言生成任务,校验保存后直接应用安排;传入 `task_id` 调整或重试同一任务。 | | `create` | 直接校验并保存结构化配置,跳过内部模型,无需再次确认。 | | `list` | 查看当前 Agent 的任务及个人待办。 | | `update` | 修改标题、内容或执行安排。 | | `run` | 启动一次 Agent/脚本执行,可用 `occurrence_id` 重试周期发生项。 | | `history` / `run_detail` | 查看执行记录与产物。 | | `inbox` | 查看待整理任务和未处理事项;读取不会完成事项。 | | `work` | 整理、开始、完成、重开、验收、要求修改或处理指定事项。 | | `status` / `enable` / `disable` | 调整自动调度启停,不改变工作完成状态。 | | `delete` | 删除任务及执行历史;运行中拒绝删除。 | 自然语言录入调用 `generate`,例如 `{ "text": "准备周五演示" }`。页面与 Agent 共用同一个生成入口,成功后直接保存并应用安排。只记录任务时不启动执行;明确要求立即执行时才启动,定时或循环工作按规则触发。生成失败保留 `task_id` 供重试,新提交重试复用客户端 `submission_id`。Agent 已掌握结构化字段时优先使用 `create`,无需再次调用内部模型;`@manual` 任务需按用户要求调用 `run` 才启动。聊天来源和交付会话自动捕获。一次性触发时间必须包含时区且晚于当前时间。 创建个人待办: ```json { "title": "准备周五演示", "kind": "user" } ``` 创建定时 Agent 工作: ```json { "title": "生成日报", "kind": "agent", "when": "0 9 * * *", "description": "汇总当天进展", "body": "读取项目记录,输出进展、阻塞和下一步。最终回复直接输出日报正文。" } ``` 处理收件箱中的验收请求,需要原任务和事项 ID: ```json { "task_id": "task_example", "command": "accept", "item_id": "review:example" } ``` 仅在用户明确确认结果后验收;Agent 不应自行批准自己的交付。 ## 工作与执行 `kind=user` 由用户完成,到时产生提醒,不需要 Agent 或 Workspace。Agent/脚本任务按定义绑定的执行目标运行。一次性 Agent 工作执行成功后等待人工验收;周期工作默认按每次成功结果完成,可在任务详情开启逐次验收。 `when` 支持 `@manual`、`time:<带时区的 ISO 时间>` 和 Cron。计划时间与截止日期不同:截止日期不会触发执行。周期任务每次发生单独记录,完成本次不结束周期。 自动调度依赖运行中的 City。错过的一次性计划进入收件箱;离线周期触发不集中补跑。审批与补充问题仍在原执行会话处理;运行中的任务暂不支持编辑或取消。 从聊天创建 Agent 工作时,结果可返回创建任务的原会话;修改执行目标不会移动该交付会话。没有创建会话的任务在执行历史查看结果。向外部聊天发送内容需明确要求执行 Agent 调用 Messenger。 ## 宿主集成 浏览器和 Electron renderer 从 `@downcity/powers/task/presentation` 导入 `task_status_label`,统一显示任务状态。该入口仅包含展示函数与类型,不加载 Node.js 调度依赖。`TaskPower` 从 `@downcity/powers/task` 导入,在 Node.js 或 Electron 主进程运行;前端引用该入口的类型时使用 `import type`。 使用界面与时间安排的详细说明见 [Task Power](/zh/docs/power/task)。 ### memory Power Source: https://downcity.ai/zh/powers-docs/builtins/memory/ City 管理唯一使用者 local 的基础资料;MemoryPower 管理持续约定和知识。所有本地 Agent 共享这个 User,切换 Federation 账号不改变身份和记忆。不使用 Jev、判定状态或 30 分钟 Session 形成任务。 ## 归属与模式 | target | profile | knowledge | | --- | --- | --- | | user | 跨 Agent 的用户约定 | 用户目标、事实、历史知识 | | agent | 当前 Agent 专属约定 | Agent 私有经验 | | current_workspace | 不支持 | 当前项目事实、决策与流程 | 首次组装或 syncshot 时加载两级 Profile,并与 Instruction 一起冻结,再由 Agent 按需查询知识。Identity 与 Instruction 合并为 Agent system,Agent Profile 独立紧随其后;User Profile 单独位于 Session 信息之前,纳入完整 system 快照,不进入 User 原文。历史约定不定义本轮任务,当前请求与权限优先。 ## remember ```json {"target":"user","mode":"profile","content":"主回答前提醒我使用英语,我写英语时简要纠正表达。"} ``` | 参数 | 行为 | | --- | --- | | content | 必填,非空正文 | | target | user、agent、current_workspace | | mode | profile 或 knowledge,默认 knowledge | | topic | knowledge 主题,默认 inbox | | memory_type | fact、preference、decision、episode、procedure、document;默认 fact | | source | knowledge 可选来源说明 | Profile 立即追加并返回 profile_id,User 写入不要求登录或 resolve_user。知识写入 evidence 与 wiki,返回 memory_id 和 evidence_id。默认是确定性追加,不自动语义融合。工具返回成功后才能确认保存。 ## 保存结果与生效方式 参数名使用 target,不使用 scope。持续行为约定应明确指定 mode=profile,省略 mode 仍默认为按需检索的 knowledge。 返回统一包含 target、mode、status、activation、location,以及 profile_id 或 memory_id/evidence_id。Profile 返回 status=saved、activation=new_session_or_syncshot、location=PROFILE.md;Knowledge 返回 status=created/updated、activation=on_recall、location=wiki。mode 不再混用为操作状态。 User / Agent 知识管理和状态查看无需 Workspace,只有项目写入需要项目。Profile 的完整管理页面仍待接入。 ## 文档维护 User 和 Agent 复用 MemoryProfile 服务,添加无篇幅硬上限,来源幂等。每个 Profile 每日本地日历日检查一次,超过 2,000 Unicode 字符时调用 profile_model_id 优化。模型生成精确编辑,代码校验唯一匹配、不重叠、版本与来源标记,写入前备份。有效约定优先,允许仍超过阈值。 保存、编辑和每日优化更新源文件,新 Session 使用最新版;已有 Session 保持旧快照,执行 syncshot 后才刷新。单范围读取失败不阻止另一范围加载。SOUL.md 仍是独立的 Agent 静态指令,不由 Memory 修改。 ## 用户中心与管理 API - memory.user_read:返回唯一使用者资料。 - memory.user_update:更新 display_name,不更换 user_id。 - memory.profile_read/status:target=user,或 target=agent 并提供 agent_id。 - memory.profile_edit:相同范围 + version + edits(old_text/new_text)。 - memory.profile_delete:相同范围 + version,备份后删除整份 Profile。 版本由 read 返回;冲突时需重新读取。profile_id 不可传给知识 read/revise/forget。专属 Desktop 管理界面待接入。 ## 配置与布局 ```ts const memory_power = new MemoryPower({ platform_root_path: "/absolute/platform-root", profile_model_id: "configured-text-model", }); ``` 无需 identity 回调、判定模型或 enabled。缺优化模型只影响每日优化。City 运行时执行检查,配置变更后重启。 ```text /user/user.json /user/PROFILE.md /user/memory/wiki/ 与 evidence/ /agents//PROFILE.md /agents//memory/wiki/ 与 evidence/ /workspaces//wiki/ 与 evidence/ ``` 旧多用户目录和旧 Agent wiki 偏好不自动迁移。此架构面向单使用者本地环境,获授权的渠道共享本地 User;宿主继续负责渠道访问授权。 知识工具保留 status、list、search、read、remember、digest、revise、forget。search 是确定性文本检索;默认 revise 追加修订说明,显式 digest 归档 Session 文本。 ### Shell Built-In Source: https://downcity.ai/zh/powers-docs/builtins/shell/ Shell 是 `@downcity/city` 提供的 Workspace 内建能力,不是 Power。Shell 持有自己的 `SandboxProvider`、为当前 Workspace 创建的持久 Sandbox,以及其中创建的全部 Shell Sessions。City 不理解 Workspace 使用哪种执行后端。 ```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: "/path/to/project", shell: new Shell({ sandbox_provider: new MicrosandboxProvider(), }), }); const city = new City({ workspaces: [workspace], }); const agent = new Agent({ id: "repo-helper" }); city.agents.add(agent); ``` Shell 命令默认使用 `target: "sandbox"`。路径由 Provider 决定:Microsandbox 将项目挂载到 `/workspace`,NativeSandboxProvider 保留宿主项目真实路径。需要宿主执行时使用 `target: "host"` 并提供清晰 `reason`,请求必须经过审批。 NativeSandboxProvider 在 macOS 上使用 Seatbelt,在 Linux 上使用 Bubblewrap。它允许读取受保护路径以外的宿主文件,允许写入 Workspace、runtime 和平台临时目录。只读授权即使位于可写根内也禁止写入;受保护路径同时禁止读写,Linux 通过空挂载隐藏其内容。该模式不提供独立 HOME,也不是文件读取白名单。网络单独配置,默认允许。 原生命令启动前默认移除名称表示令牌、API key、密码、secret、凭证路径的环境变量,以及 SSH agent 变量。宿主可以通过 `new NativeSandboxProvider({ allowed_credential_env: ["NPM_TOKEN"] })` 精确放行所需变量;名称按大小写精确匹配。Downcity 内部认证令牌始终移除。PATH、HOME 和非凭证构建配置仍保留。该机制仅过滤传入变量:login 脚本和可读宿主文件仍可能提供凭证,未识别的变量名称也不会被检测。 宿主可以显式授权 SSH 密钥管家,私钥文件仍不开放: ```ts const sandbox_provider = new NativeSandboxProvider({ ssh: { socket_path: "/absolute/path/to/agent.sock", known_hosts_path: "/absolute/path/to/dedicated_known_hosts", }, }); ``` 两个路径必须存在,分别为 Unix socket 和普通文件,且网络策略必须为 `allow`。宿主应事先核实远程主机指纹并维护专用 known_hosts;不自动信任未知主机。命令传入的 SSH_AUTH_SOCK 会被替换为宿主授权值;未配置 SSH 授权时始终移除该变量,即使普通凭证放行名单包含它。Git 的默认 SSH 调用忽略用户配置,不加载私钥、禁用 agent forwarding,并严格校验主机。直接执行 ssh 时需要自行指定相应参数;Git 默认值不是任意命令的强制策略。 此授权授予 socket 内身份的签名使用能力,不限制到某一仓库;命令可以调用 agent 协议,不能通过标准协议导出私钥。CLI/Desktop 不会自动授权 SSH。Desktop 的 Workspace 设置提供“SSH 身份”分区,可指定 socket 并导入已核验的 known_hosts;应用前会弹出原生确认框。授权记录持久化到 Workspace,socket 身份变化后要求重新授权。变更会中断该 Workspace 的任务并重建运行实例;若重建失败会报告错误并阻止继续进入,需重试。CLI 尚未提供授权管理命令,但创建 Workspace 时会核验并使用已保存的记录。受保护目录内的专用 known_hosts 和 socket 可按精确路径开放。`sandbox.get` 的 `allowed_read_paths` 列出 known_hosts 的精确读取例外,路径解释不会再被其受保护父目录误判;同目录其他文件继续受保护。socket 属于连接能力,不属于文件读取例外,当前路径解释不回答 socket 是否可连接。 通过 `context.sandbox.get` 查看生效边界。`writable_roots` 是宽范围写入授权,`denied_write_paths` 优先于它,包含只读与受保护子树;`denied_read_paths` 表示受保护内容。`context.sandbox.explain_path` 会先检查写入排除项,再检查可写根。原生策略创建时解析现有路径别名;路径解释仍采用词法判断。 原生 Provider 自检使用自有临时文件验证允许写入、越界写入、只读授权、受保护内容和子进程继承;另外创建自有本地 TCP 服务,确认允许网络时可达、禁止网络时被拦。网络探针不访问外网,其验证范围为 TCP 到宿主 loopback,不覆盖所有协议。探针超时、启动失败或自检异常都判定失败。Linux 如果无法安全落实可写根内尚不存在的限制路径,会拒绝执行。 同一 Workspace 的 Chat Session 共享 Sandbox 资源。原生执行使用宿主工具链与 HOME,不会为每个 Workspace 提供独立机器或临时目录。`shell_id` 标识 Shell Session,不能与 Chat `session_id` 混用。 首次 Sandbox 命令启动前,Shell 等待 `SandboxProvider.check()`;并发命令共享一次检查。检查不通过或抛错都会阻止 Sandbox 启动,不会自动转为宿主执行。结果会缓存,直到显式 `reset_sandbox()` 成功,下一条命令才重新检查;不支持 reset 的 Provider 需要重建 Shell/Workspace 才能重试。reset 期间拒绝新命令,reset 失败后继续阻止 Sandbox 执行,直到重试 reset 成功。dispose 会拒绝新工作,并阻止仍在等待自检的命令启动。Host 执行继续走独立审批流程。 关闭 City 或 Workspace 保留持久文件。原生执行没有需要停止的持久 VM,命令生命周期由 Shell 管理。 生命周期、环境变量和审批细节见 [Shell 与 Sandbox](/zh/agent-sdk-docs/local-agent/shell-reference)。 ### NotesPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/notes-power/ ```ts import { Power, type PowerActions, type PowerContext, } from "@downcity/city/power"; class NotesPower extends Power { readonly name = "notes"; readonly title = "Project Notes"; readonly description = "Describe the project notes folder."; system(context: PowerContext) { return `Project notes folder: ${context.workspace.path}/.notes`; } readonly actions: PowerActions = { status: { execute: async ({ context }) => ({ success: true, data: { workspace_path: context.workspace.path, storage_path: context.storage.path, }, }), }, }; } const notes_power = new NotesPower(); ``` 宿主把 `notes_power` 交给 City;全部 Agent 自动获得它。进入 Workspace 后,可以通过 `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action()` 显式调用; Session 会从同一 City execution view 收集 System 与 Hook。 这个 Power 没有长期资源,因此不需要 Lifecycle。用户项目文件应使用 `context.workspace.files`,Power 私有状态应使用 `context.storage.files`。 ### ReviewGuardPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/review-guard-power/ ## 场景 你希望在某条运行链路里强制执行一条规则: - 如果请求没有带 `reviewed = true` - 就直接阻断后续执行 这类“前置阻断判断”就是最典型的 `guard` 场景。 ## 示例 ```ts import { Power, type PowerHooks } from "@downcity/city/power"; class ReviewGuardPower extends Power { readonly name = "review_guard"; readonly title = "Review Guard"; readonly description = "Blocks requests that were not reviewed."; readonly hooks: PowerHooks = { guard: { "review.require_checked": [ async ({ value }) => { const body = value as { reviewed?: unknown }; if (body.reviewed !== true) { throw new Error("review is required before continuing"); } }, ], }, }; } // City 已登记并把 review_guard 绑定到 entry 的 Agent/Workspace。 await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).guard("review.require_checked", { reviewed: true, }); ``` ## 为什么这里不是 pipeline 因为你不是想“改值”,而是想: - 允许继续 - 或者直接阻断 这正是 guard 的语义。 ## 相关文档 - [Power Hooks 和 Resolve](/zh/agent-sdk-docs/powers/power-hooks) - [自定义 Power](/zh/agent-sdk-docs/powers/custom-power) ### MessageAugmentPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/message-augment-power/ ## 场景 你希望在消息进入主执行链之前,自动补一点上下文: - 当前页面标题 - 当前来源标记 这类“进链前增强值”的场景,非常适合 `pipeline`。 ## 示例 ```ts import { Power, type PowerJsonObject, type PowerHooks, } from "@downcity/city/power"; class MessageAugmentPower extends Power { readonly name = "message_augment"; readonly title = "Message Augment"; readonly description = "Adds extra fields to inbound message payloads."; readonly hooks: PowerHooks = { pipeline: { "chat.enrich_message": [ async ({ value }) => { const body = value as PowerJsonObject; return { ...body, source: body.source || "web", pageTitle: body.pageTitle || "Untitled Page", }; }, ], }, }; } // City 已登记并把 message_augment 绑定到 entry 的 Agent/Workspace。 const message = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).pipeline("chat.enrich_message", { text: "Please summarize this page", }); ``` ## 这个例子想表达什么 - pipeline 的重点是“拿到一个值,返回一个新值” - 多个 Power 可以继续往后接着改 ## 相关文档 - [Power Hooks 和 Resolve](/zh/agent-sdk-docs/powers/power-hooks) - [Power 总览](/zh/agent-sdk-docs/powers/overview) ### AuditEffectPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/audit-effect-power/ ## 场景 你想记录某类事件: - 某条消息进来了 - 某个动作被触发了 但你不想: - 改写主值 - 阻断流程 这时最适合 `effect`。 ## 示例 ```ts import { Power, type PowerHooks } from "@downcity/city/power"; class AuditEffectPower extends Power { readonly name = "audit_effect"; readonly title = "Audit Effect"; readonly description = "Writes one audit line for every observed event."; readonly hooks: PowerHooks = { effect: { "audit.observe": [ async ({ value, power }) => { console.log(`[${power}] observed`, value); }, ], }, }; } // City 已登记并把 audit_effect 绑定到 entry 的 Agent/Workspace。 await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).effect("audit.observe", { event: "incoming_message", session_id: "sess_001", }); ``` ## 为什么这里不是 pipeline 因为你并不关心返回一个新值,只关心副作用本身。 ## 相关文档 - [Power Hooks 和 Resolve](/zh/agent-sdk-docs/powers/power-hooks) ### RoleResolverPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/role-resolver-power/ ## 场景 你希望某条链路里有人可以问: - “当前这个用户的角色是什么?” 这种问题不是多 Power 协同改值,而是必须有一个唯一答案。 这就是 `resolve` 的典型场景。 ## 示例 ```ts import { Power, type PowerResolveHook, } from "@downcity/city/power"; class RoleResolverPower extends Power { readonly name = "role_resolver"; readonly title = "Role Resolver"; readonly description = "Resolves one final role for a user."; private readonly resolveRole: PowerResolveHook = 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"; return "member"; }; readonly resolves = { "auth.resolve_role": this.resolveRole, }; } // City 已登记并把 role_resolver 绑定到 entry 的 Agent/Workspace。 const role = await city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).resolve<{ userId: string }, string>( "auth.resolve_role", { userId: "owner" }, ); ``` ## 为什么不能有两个 resolver 因为这类点的语义不是: - 大家都来补一点 而是: - 最终到底谁说了算 所以当前实现里,同一个 resolve 点重复注册会直接报错。 ## 相关文档 - [Power Hooks 和 Resolve](/zh/agent-sdk-docs/powers/power-hooks) - [Power Availability](/zh/agent-sdk-docs/powers/power-availability) ### SnapshotHttpPower 场景 Source: https://downcity.ai/zh/powers-docs/examples/snapshot-http-power/ ## 场景 你想让某个 Power 对外提供一个只读快照接口: - `GET /api/notes/snapshot` 这类事情就很适合 `power.http.server`。 ## 当前边界 这个能力针对的是完整 runtime HTTP 装配链。 也就是说: - `power.http.server` 不是 `city.powers.scope({ agent_id: agent.id, workspace_id: workspace.id }).run_action(...)` 这一类显式调用 - 当前由 Downcity 暴露 HTTP,并为运行中的 Agent 自动挂载 Power HTTP 路由 - 如果你需要这一层,就应该把它理解成更完整的 host/runtime 集成能力 ### Power Runtime Composition Source: https://downcity.ai/zh/powers-docs/examples/power-runtime-composition/ 设计 Power 时,一个很关键的问题是: 这段逻辑到底应该待在 Power Actions / hooks 里,还是应该放进 lifecycle-owned Power runtime? ## 适合保持轻量 Power 的场景 - 显式用户 action - hook 参与 - system prompt 注入 - 较小的运行时辅助协议 - 较小的 runtime HTTP surface ## 更适合 lifecycle-owned runtime 的场景 - queue worker - cron engine - 持久 session pool - 长连接 - stop 时必须回收的内存 runtime state ## 一个实用判断 如果这段能力必须被 start、stop、recover 或随时间 poll,那它通常更适合 lifecycle-owned Power runtime,而不只是一个薄 action。 ## 可以参考的内建例子 - `messenger`:queue worker 归属于 Power 实例 - `task`:cron engine 归属于 Power 实例 - `schedule`:纯 lifecycle 基础设施 Power - `shell`:长期 shell sessions 归属于 Power 实例 ## 相关文档 - [Power Design Patterns](/zh/agent-sdk-docs/powers/design-patterns) - [Power Lifecycle](/zh/agent-sdk-docs/powers/lifecycle) - [Built-ins Overview](/zh/powers-docs/builtins/overview) ## UI SDK ### UI SDK Source: https://downcity.ai/en/ui-sdk-docs/ `@downcity/ui` is the shared React + Tailwind primitive package used across Downcity surfaces such as Console UI and other host applications. If you want one page per component, continue with [Components](/en/ui-sdk-docs/components). If you want setup guidance before using the components, continue with [Installation](/en/ui-sdk-docs/installation). ## What It Includes - Console-style primitives such as `Button`, `Badge`, `Card`, `Checkbox`, `Input`, `Label`, `Textarea`, `Separator`, `Dialog`, `Sheet`, `DropdownMenu`, `Popover`, `Tabs`, `Toggle`, `ToggleGroup`, `Tooltip`, `Skeleton`, and `Toaster` - The shared `cn` utility for Tailwind class composition - Two style entrypoints: `source.css` and `styles.css` - Public types: `DowncityButtonVariant`, `DowncityButtonSize`, `DowncityCardSize`, `DowncityDropdownMenuItemVariant`, and `DowncityToasterTheme` ## Reading Paths - [Installation](/en/ui-sdk-docs/installation) - [Components](/en/ui-sdk-docs/components) - [Modules](/en/ui-sdk-docs/modules) - [Host Integration](/en/ui-sdk-docs/host-integration) - [Development](/en/ui-sdk-docs/development) ## Public Export Structure ### Utilities and Types - `cn` - `DowncityButtonVariant` - `DowncityButtonSize` - `DowncityCardSize` - `DowncityDropdownMenuItemVariant` - `DowncityToasterTheme` ### Form Primitives - `Input` - `Textarea` - `Checkbox` - `Label` ### Containers and Display - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` - `Badge` - `Separator` - `Skeleton` - `Tabs` ### Actions and State Switching - `Button` - `Toggle` - `ToggleGroup` ### Overlays and Feedback - `Dialog` - `Sheet` - `DropdownMenu` - `Popover` - `Tooltip` - `Toaster` ## Component Coverage - Actions: `Button`, `Badge`, `Toggle`, `ToggleGroup` - Forms: `Input`, `Textarea`, `Checkbox`, `Label` - Containers: `Card`, `Separator`, `Skeleton`, `Tabs` - Overlays: `Dialog`, `DropdownMenu`, `Popover`, `Sheet`, `Tooltip`, `Toaster` ## Next Reading - [Installation](/en/ui-sdk-docs/installation) - [Components](/en/ui-sdk-docs/components) - [Host Integration](/en/ui-sdk-docs/host-integration) ### Installation Source: https://downcity.ai/en/ui-sdk-docs/installation/ This page covers one job: how to integrate `@downcity/ui` correctly into a React + Tailwind host app. ## Install The Package ```bash npm install @downcity/ui ``` ## React Version Requirements `@downcity/ui` supports React 18.2 and React 19. Keep `react` and `react-dom` on the same major version in the host application. ```bash # React 18 npm install react@^18.2.0 react-dom@^18.2.0 # React 19 npm install react@^19.0.0 react-dom@^19.0.0 ``` ## Import The Styles ```css @import "tailwindcss"; @import "@downcity/ui/source.css"; @import "@downcity/ui/styles.css"; ``` Recommended order: 1. `tailwindcss` 2. `@downcity/ui/source.css` 3. `@downcity/ui/styles.css` 4. your host app global CSS ## Why Both Entry Points Exist ### `@downcity/ui/source.css` - Only declares the Tailwind scanning source - Makes sure the host app includes utilities used inside UI SDK components - Does not inject tokens or base styles ### `@downcity/ui/styles.css` - Owns theme variables and the base layer - Defines semantic tokens such as `background`, `foreground`, `popover`, `border`, and `ring` - Makes semantic utility classes work in the host app ## Minimal Working Example ```tsx import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@downcity/ui"; export function AgentSettingsCard() { return ( Agent Settings
); } ``` ## Integration Rules - Use `className` mainly for layout, width, spacing, and local page adjustments - Prefer built-in `variant`, `size`, and composition patterns instead of rebuilding a second visual system - For overlays, follow the primitive composition directly, such as `Dialog + DialogContent + DialogHeader` ## Next Reading - [Components](/en/ui-sdk-docs/components) - [Modules](/en/ui-sdk-docs/modules) - [Development](/en/ui-sdk-docs/development) ### Button Source: https://downcity.ai/en/ui-sdk-docs/components/button/ `Button` is the core action primitive in `@downcity/ui`. ## Import ```tsx import { Button } from "@downcity/ui"; ``` ## Basic Example ## Variants ## Sizes ## Icon Button ## Usage Notes - Use `size="sm"` or `size="default"` for page-level actions - Use `variant="ghost"` for toolbar controls - Use `variant="destructive"` for destructive actions - Prefer `icon-*` sizes instead of hand-written `w-* h-*` ## Related Exports - `Button` - `buttonVariants` ### Badge Source: https://downcity.ai/en/ui-sdk-docs/components/badge/ `Badge` is used for short status labels and lightweight metadata. ## Import ```tsx import { Badge } from "@downcity/ui"; ``` ## Basic Example ## Variants - `default` - `secondary` - `destructive` - `outline` - `ghost` - `link` ## Common Pattern ```tsx import { Badge, Card, CardContent } from "@downcity/ui"; export function AgentCard() { return ( Research Agent Online ); } ``` ## Usage Notes - Use `secondary` or `destructive` for state - Use `outline` for category labels - Do not use `Badge` as a primary action control ### Card Source: https://downcity.ai/en/ui-sdk-docs/components/card/ `Card` is the main structured container component family. ## Import ```tsx import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, } from "@downcity/ui"; ``` ## Basic Example ## Subcomponents - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` ## Sizes - `size="default"` - `size="sm"` ## Usage Notes - Prefer the full composition instead of dumping everything into `CardContent` - Use `CardAction` for top-right auxiliary actions - Use `CardFooter` for the bottom action row ### Checkbox Source: https://downcity.ai/en/ui-sdk-docs/components/checkbox/ `Checkbox` is used for boolean and multi-select input. ## Import ```tsx import { Checkbox, Label } from "@downcity/ui"; ``` ## Basic Example ## Usage Notes - Pair it with `Label` - Group related checkboxes inside the same content block or settings section - If the interaction behaves like a pressed button, consider `Toggle` ### CodeBlock Source: https://downcity.ai/en/ui-sdk-docs/components/code-block/ `CodeBlock` provides a language label, horizontal scrolling, and copy behavior; the header and code body have no separator. Light mode uses GitHub Light and dark mode uses One Dark Pro. The `code` prop uses lightweight built-in token coloring; full syntax highlighting belongs in the MDX, Markdown, or host build pipeline, so the component does not load Shiki in the browser. Build tooling can also pass highlighted `children`, `data-raw`, and `data-language` directly to the component. ### Command Source: https://downcity.ai/en/ui-sdk-docs/components/command/ `Command` works well for command palettes, resource search, and quick navigation. It owns filtering and keyboard selection while the host owns opening and executing actions. Keep every `CommandItem` inside a `CommandGroup`. Compose the full `Command` inside a `Dialog` when you need a modal command palette. ### ContextMenu Source: https://downcity.ai/en/ui-sdk-docs/components/context-menu/ `ContextMenu` provides right-click triggering, keyboard navigation, groups, checked items, and submenus. ```tsx import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuShortcut, ContextMenuTrigger, } from "@downcity/ui"; export function FileActions() { return ( }>Right-click for actions Open Rename F2 ); } ``` Keep `ContextMenuItem` inside `ContextMenuGroup`. The trigger region should be focusable, while the host owns action labels and behavior. ### Dialog Source: https://downcity.ai/en/ui-sdk-docs/components/dialog/ `Dialog` is for modal interactions that temporarily block the current context. ## Import ```tsx import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, } from "@downcity/ui"; ``` ## Basic Example ## Key Exports - `Dialog` - `DialogTrigger` - `DialogPortal` - `DialogClose` - `DialogOverlay` - `DialogContent` - `DialogHeader` - `DialogFooter` - `DialogTitle` - `DialogDescription` ## Usage Notes - Do not omit `DialogTitle` - Prefer `Sheet` when the content is long or panel-like - Use `showCloseButton={false}` if you need to disable the built-in close button ### DropdownMenu Source: https://downcity.ai/en/ui-sdk-docs/components/dropdown-menu/ `DropdownMenu` is for grouped, lower-priority actions. ## Import ```tsx import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, Button, } from "@downcity/ui"; ``` ## Basic Example ## Available Building Blocks - `DropdownMenuItem` - `DropdownMenuGroup` - `DropdownMenuLabel` - `DropdownMenuSeparator` - `DropdownMenuShortcut` - `DropdownMenuCheckboxItem` - `DropdownMenuRadioGroup` - `DropdownMenuRadioItem` - `DropdownMenuSub` - `DropdownMenuSubTrigger` - `DropdownMenuSubContent` ## Usage Notes - Use `variant="destructive"` for destructive actions - Group similar commands with `DropdownMenuGroup` - Avoid putting long forms into a dropdown menu ### Empty Source: https://downcity.ai/en/ui-sdk-docs/components/empty/ `Empty` provides media, title, description, and action regions so empty states stay consistent across screens. The host owns action behavior, loading, and product copy. ### Input Source: https://downcity.ai/en/ui-sdk-docs/components/input/ `Input` is the main single-line text field. ## Import ```tsx import { Input, Label } from "@downcity/ui"; ``` ## Basic Example ## Good Fits - Single-line text - Search fields - Model names, paths, keys, and short settings ## Usage Notes - Pair it with `Label` - Use `Textarea` for longer content - Use `aria-invalid` for invalid state semantics ### FormField Source: https://downcity.ai/en/ui-sdk-docs/components/form-field/ `FormField` composes a label, description, validation message, and any control while your host owns form state. ### Foundation Primitives Source: https://downcity.ai/en/ui-sdk-docs/components/foundation-primitives/ These components use the existing Base UI runtime and Vibecape design tokens. No Shadcn visual styles or runtime dependencies are required. ```tsx DetailsContent Delete project DC ⌕ ``` Also available: `AlertDialog`, `Drawer`, `Pagination`, `RadioGroup`, `ScrollArea`, and `Table`. ## Advanced interaction primitives `FileUpload` only reports browser `File` objects to the host. `DataTable` receives data and column definitions, keeping sorting, data fetching, and selection ownership in the application. `HoverCard` provides a compact delayed summary, and `ResizablePanelGroup` composes adjustable work areas. ```tsx row.id} /> Navigation Content ``` ## Color themes Every primitive consumes the same semantic variables: `background`, `foreground`, `surface-subtle`, `control-surface`, `divider`, `interaction-hover`, `interaction-selected`, and `primary`. Host applications set the theme through `ThemeContainer`, without changing component classes. ```tsx ``` The showcase keeps its page chrome fixed and wraps only the component display area in ThemeContainer. It includes `neutral`, `zinc`, `slate`, `stone`, and `blue` presets, each compatible with light and dark appearance. ## Theme container `ThemeContainer` is the theme boundary for the complete component tree. It provides colors, control surfaces, borders, interaction states, and floating tokens together. Individual components do not receive theme props or theme-specific classes. ```tsx import { Button, Input, ThemeContainer } from "@downcity/ui"; export function ThemedApplication() { return ( ); } ``` `variant` supports `neutral`, `zinc`, `slate`, `stone`, and `blue`; `mode` supports `light` and `dark`. Dropdown, ContextMenu, Select, Popover, Tooltip, HoverCard, Dialog, AlertDialog, Sheet, Drawer, and ImagePreview overlays automatically mount inside ThemeContainer, so no separate theme synchronization is required. The showcase validates every component in four ThemeContainer blocks: Neutral Light, Neutral Dark, Blue Light, and Blue Dark. ### ImagePreview Source: https://downcity.ai/en/ui-sdk-docs/components/image-preview/ Pass a data URL, Blob URL, or remote URL. The component never reads local files or uses host bridges. ```tsx ``` ### Item Source: https://downcity.ai/en/ui-sdk-docs/components/item/ `Item` standardizes media, title, description, and trailing actions without binding the layout to menu or routing semantics. Variants are `default`, `outline`, and `muted`; sizes are `default` and `sm`. When interaction semantics are required, use `render={ ); } ``` ## Themes - `light` - `dark` - `system` ## Usage Notes - Mount `Toaster` once near the app root - Keep using `toast` from `sonner` for actual notifications - Avoid mounting multiple toaster containers per page ### Toaster Source: https://downcity.ai/en/ui-sdk-docs/components/sonner/ `Toaster` wraps the `sonner` library with Downcity's theme-aware icons and styling. It provides consistent notification icons for success, info, warning, error, and loading states. ## Import ```tsx import { Toaster } from "@downcity/ui"; import "@downcity/ui/styles.css"; ``` ## Usage Place `` once at the app root. It renders a toast container that can be triggered from anywhere in the component tree. ```tsx function Layout({ children }) { return ( <> {children} ); } ``` Trigger toasts using the `sonner` toast API: ```tsx import { toast } from "sonner"; toast.success("Agent started"); toast.error("Session timeout"); toast.info("Processing..."); toast.warning("Low credits"); toast.loading("Thinking..."); ``` ## Props All standard `sonner` `ToasterProps` are supported, including: | Prop | Type | Default | Description | |------|------|---------|-------------| | `position` | `string` | `"bottom-right"` | Toast position | | `richColors` | `boolean` | `false` | Use rich colored backgrounds | | `closeButton` | `boolean` | `false` | Show close button | | `duration` | `number` | `4000` | Auto-dismiss duration in ms | ### Workboard Source: https://downcity.ai/en/ui-sdk-docs/components/workboard/ `Workboard` renders a dashboard-style board showing all agents' status, activity, signals, and recent history. It is the primary UI for monitoring a Downcity agent fleet. ## Import ```tsx import { Workboard } from "@downcity/ui"; import "@downcity/ui/styles.css"; ``` ## Usage ```tsx function AgentDashboard() { const [board, setBoard] = useState(null); return ( setSelectedId(id)} onRefresh={() => fetchBoard()} /> ); } ``` ## Props ```tsx interface DowncityWorkboardProps { board: DowncityWorkboardBoardSnapshot | null; loading?: boolean; selectedAgentId?: string; onSelectAgent?: (agentId: string) => void; onRefresh?: () => void; className?: string; } ``` | Prop | Type | Description | |------|------|-------------| | `board` | `DowncityWorkboardBoardSnapshot` | Current board snapshot with summary and agent list | | `loading` | `boolean` | Show loading state when refreshing | | `selectedAgentId` | `string` | Currently selected agent | | `onSelectAgent` | `(id) => void` | Fired when user selects an agent | | `onRefresh` | `() => void` | Fired when user triggers refresh | ## Related types See `DowncityWorkboardBoardSnapshot`, `DowncityWorkboardAgentItem`, `DowncityWorkboardActivityItem`, `DowncityWorkboardSignalItem` from `@downcity/ui`. ### WorkboardStage Source: https://downcity.ai/en/ui-sdk-docs/components/workboard-stage/ There is no public `WorkboardStage` component. The stage is an internal layer of the composite [Workboard](./workboard): a fixed-size coordinate space where zones and agent nodes are positioned. `@downcity/ui` does not export the stage component, its zone definitions, or its coordinate helpers. Render `Workboard` and let it own the stage. ## What is public The board's game geometry is reachable through one exported builder: ```tsx import { buildWorkboardGameMapConfig } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` `buildWorkboardGameMapConfig` returns a `DowncityWorkboardGameMapConfig` describing zones, actors, corridors, patrols, and points of interest — the same geometry the stage renders. ## Related - [Workboard](./workboard) — the composite board you render - [WorkboardGame](./workboard-game) — the exported game map builder and public game types - [WorkboardStageMap](./workboard-stage-map) — the pixel map used inside the atlas ### WorkboardStageMap Source: https://downcity.ai/en/ui-sdk-docs/components/workboard-stage-map/ There is no public `WorkboardStageMap` component. It is the pixel-art village renderer used inside the composite [Workboard](./workboard) atlas: buildings, rooms, and paths are drawn from the game map geometry to represent agent activity zones. `@downcity/ui` does not export this renderer. Render `Workboard` and let it draw the atlas. ## What is public The geometry this map draws comes from the exported builder: ```tsx import { buildWorkboardGameMapConfig } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` `gameMap.zones`, `gameMap.pointsOfInterest`, and `gameMap.actors` are the values the pixel map renders. Build them once and pass them to your own overlay when you need the same layout. ## Related - [Workboard](./workboard) — the composite board you render - [WorkboardGame](./workboard-game) — the exported game map builder and public game types - [WorkboardStage](./workboard-stage) — the stage layer behind the atlas ### Workboard Game Source: https://downcity.ai/en/ui-sdk-docs/components/workboard-game/ `Workboard` composes a game-style interactive map for agent activity. The atlas, room, inspector, and pixel-sprite renderers are internal parts of that composition — they are not separate public components, so you do not import them individually. ## Game map config The only game-specific value `@downcity/ui` exports is the map builder. Pass the same input the board uses to derive a `DowncityWorkboardGameMapConfig`: ```tsx import { buildWorkboardGameMapConfig, type DowncityWorkboardBoardSnapshot } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` | Option | Type | Meaning | |--------|------|---------| | `board` | `DowncityWorkboardBoardSnapshot` | Current board snapshot | | `activeZoneId` | `DowncityWorkboardZoneId` | Zone to focus | | `selectedAgentId` | `string` | Currently selected agent | The returned `DowncityWorkboardGameMapConfig` describes zones, actors, corridors, patrols, and points of interest. `Workboard` builds it internally; call the builder directly only when you need the same geometry for your own overlay. ## Rendering the game Render the board and let it own the game mode, HUD, zone navigation, and motion: ```tsx import { Workboard } from "@downcity/ui"; import "@downcity/ui/styles.css"; setSelectedId(id)} /> ``` ## Public game types These types are exported for configuring and typing the map. The atlas, room, inspector, and sprite renderers consume them internally. | Type | Purpose | |------|---------| | `DowncityWorkboardGameMapConfig` | Full game map config returned by the builder | | `DowncityWorkboardGameZone` | One zone on the map | | `DowncityWorkboardGameActor` | One agent actor with position and route | | `DowncityWorkboardGameRoute` | Patrol or corridor path | | `DowncityWorkboardGamePointOfInterest` | Named point on the map | | `DowncityWorkboardGameAreaLabel` | Area label rendered on the map | ## Related - [Workboard](./workboard) — the composite board you render - [WorkboardStage](./workboard-stage) and [WorkboardStageMap](./workboard-stage-map) — the board's internal stage layers ### ButtonGroup Source: https://downcity.ai/en/ui-sdk-docs/components/button-group/ `ButtonGroup` owns only layout, merged boundaries, and outer corner radii. Each `Button` keeps its own `variant` and action hierarchy. `orientation` accepts `horizontal` and `vertical`; the default is `horizontal`. ### UI SDK Modules Source: https://downcity.ai/en/ui-sdk-docs/modules/ This page lists the full public module surface of `@downcity/ui`. ## Shortcuts The shortcut module provides cross-platform matching, registration, and a React provider. `Mod` means `Command` on macOS and `Ctrl` on Windows/Linux. ```tsx import { ShortcutProvider, use_register_shortcuts } from "@downcity/ui"; function AppShortcuts() { use_register_shortcuts(() => [{ id: "editor.bold", title: "Bold", keys: ["Mod+B"], scope: "editor", run: () => { document.execCommand("bold"); return true; }, }], []); return null; } ``` It supports combinations such as `Mod+B`, `Mod+L`, `Mod+I`, and `Mod+,`. Host applications provide the actions; the UI SDK does not impose business behavior. ## Utilities and Types ### `cn` - Purpose: merge Tailwind classes and resolve conflicts safely ### `DowncityButtonVariant` - Values: `default`, `outline`, `secondary`, `ghost`, `destructive`, `link` ### `DowncityButtonSize` - Values: `default`, `xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg` ### `DowncityCardSize` - Values: `default`, `sm` ### `DowncityDropdownMenuItemVariant` - Values: `default`, `destructive` ### `DowncityContextMenuItemVariant` - Values: `default`, `destructive` ### `DowncityItemVariant`, `DowncityItemSize` - Item variants: `default`, `outline`, `muted` - Item sizes: `default`, `sm` ### `DowncitySpinnerSize` - Values: `sm`, `default`, `lg` ### `DowncityToasterTheme` - Values: `light`, `dark`, `system` ## Style Modules ### `@downcity/ui/source.css` - Role: declare the Tailwind scanning source ### `@downcity/ui/styles.css` - Role: provide design tokens and the base layer ## Actions and State Modules ### `Button` - Exports: `Button`, `buttonVariants` ### `ButtonGroup` - Exports: `ButtonGroup` - Exported types: `DowncityButtonGroupProps`, `DowncityButtonGroupOrientation` ### `Badge` - Exports: `Badge`, `badgeVariants` ### `Toggle` - Exports: `Toggle`, `toggleVariants` ### `ToggleGroup` - Exports: `ToggleGroup`, `ToggleGroupItem` ### `Spinner` - Exports: `Spinner` - Purpose: loading feedback in buttons, items, and asynchronous regions ### `Kbd` - Exports: `Kbd`, `KbdGroup` - Purpose: display shortcuts and key combinations ### `CodeBlock` - Exports: `CodeBlock` - Exported type: `DowncityCodeBlockProps` ### `Typography` - Exports: `H1`–`H6`, `Paragraph`, `Lead`, `Small`, `Muted`, `Strong`, `Emphasis`, `InlineCode`, `Blockquote`, `UnorderedList`, `OrderedList`, `ListItem`, `TaskListItem`, `DefinitionList`, `DefinitionTerm`, `DefinitionDescription`, `FootnoteReference`, `Footnotes`, `FootnoteItem`, `Anchor`, `Hr`, `Annotation` - Exported types: `DowncityAnnotationProps`, `DowncityAnnotationTone`, and Typography prop types - Purpose: render headings, prose, quotes, lists, links, inline code, and semantic annotations - Purpose: render plain source or build-time highlighted code with copy behavior ## Form Modules ### `Input` - Exports: `Input` ### `Textarea` - Exports: `Textarea` - Exported type: `TextareaProps` ### `Checkbox` - Exports: `Checkbox` ### `Label` - Exports: `Label` ### `Select` - Exports: `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectGroup`, `SelectLabel`, `SelectItem`, `SelectSeparator` - Purpose: single-value selection with grouped options ### `Slider` - Exports: `Slider` - Purpose: scalar or range value selection ### `Switch` - Exports: `Switch` - Purpose: toggle one boolean setting ## Containers and Display Modules ### `ChatPanel` - Exports: `ChatPanel`, `ChatHistory`, `ChatMessage`, `ChatComposer`, `session_message_to_chat_message` - Exported types: `DowncityChatPanelProps`, `DowncityChatMessage`, `DowncityChatThread`, `DowncityChatStatus`, `DowncityChatSubmitInput` - Purpose: runtime-agnostic Agent chat UI with a header, thread history, messages, empty state, streaming state, stop action, and composer. - The components are controlled. Hosts provide data and own Agent, Session, network, and persistence behavior through callbacks. ```tsx import { ChatPanel } from "@downcity/ui"; import "@downcity/ui/styles.css"; send_message(text)} on_stop={() => stop_generation()} render_message={({ message }) => render_markdown(message.content)} /> ``` `session_message_to_chat_message` converts Session SDK canonical messages into the Chat presentation model. The UI includes Duobox-style messages, tool activities, interactions, changed files, history, and composer surfaces, while the host owns the Session lifecycle and response actions. ### `Card` - Exports: - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` ### `Separator` - Exports: `Separator` ### `Skeleton` - Exports: `Skeleton` ### `Tabs` - Exports: - `Tabs` - `TabsList` - `TabsTrigger` - `TabsContent` - `tabsListVariants` ### `Settings` - Exports: `SettingsContainer`, `SettingSection`, `SettingGroup`, `SettingItem`, `InfoRow` - Purpose: compact settings screens and grouped detail rows ### `Item` - Exports: `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions`, `ItemGroup`, `item_variants` - Purpose: compose resource lists, search results, and settings entries ### `Empty` - Exports: `Empty`, `EmptyHeader`, `EmptyMedia`, `EmptyTitle`, `EmptyDescription`, `EmptyContent` - Purpose: first-use, no-data, and no-results states ### `Workboard` - Exports: `Workboard` - Exported types: - `DowncityWorkboardProps` - `DowncityWorkboardBoardSnapshot` - `DowncityWorkboardAgentItem` - `DowncityWorkboardActivityItem` - Purpose: render a pixel-style game world across multiple agents with a fixed 40 x 24 tile world canvas, full-ratio normal view, cover-scaled fullscreen view, a Smallville-style downcity atlas, open state buildings, tiled interior room maps, a portal rail, keyboard map control, sprite patrols, quest paths, in-room wall boards, blueprint tables, a codex log, and focused inspection ### `buildWorkboardGameMapConfig` - Exports: `buildWorkboardGameMapConfig` - Exported types: - `DowncityWorkboardGameMapConfig` - `DowncityWorkboardGameZone` - `DowncityWorkboardGameActor` - `DowncityWorkboardGameRoute` - `DowncityWorkboardGamePointOfInterest` - `DowncityWorkboardGameAreaLabel` - `DowncityWorkboardGameAtlasProps` - `DowncityWorkboardGameRoomProps` - `DowncityWorkboardGameInspectorProps` - `DowncityWorkboardGameHudProps` - Purpose: map the public `workboard` snapshot into a full game-map configuration that custom hosts can reuse for zones, actors, routes, gates, stations, props, and area labels ## Overlays and Feedback Modules ### `Dialog` - Exports: - `Dialog` - `DialogTrigger` - `DialogPortal` - `DialogClose` - `DialogOverlay` - `DialogContent` - `DialogHeader` - `DialogFooter` - `DialogTitle` - `DialogDescription` ### `Sheet` - Exports: - `Sheet` - `SheetTrigger` - `SheetClose` - `SheetPortal` - `SheetOverlay` - `SheetContent` - `SheetHeader` - `SheetFooter` - `SheetTitle` - `SheetDescription` ### `DropdownMenu` - Exports: - `DropdownMenu` - `DropdownMenuTrigger` - `DropdownMenuPortal` - `DropdownMenuContent` - `DropdownMenuGroup` - `DropdownMenuLabel` - `DropdownMenuItem` - `DropdownMenuCheckboxItem` - `DropdownMenuRadioGroup` - `DropdownMenuRadioItem` - `DropdownMenuSeparator` - `DropdownMenuShortcut` - `DropdownMenuSub` - `DropdownMenuSubTrigger` - `DropdownMenuSubContent` ### `ContextMenu` - Exports: `ContextMenu`, `ContextMenuTrigger`, `ContextMenuContent`, `ContextMenuGroup`, `ContextMenuLabel`, `ContextMenuItem`, `ContextMenuCheckboxItem`, `ContextMenuRadioGroup`, `ContextMenuRadioItem`, `ContextMenuSeparator`, `ContextMenuShortcut`, `ContextMenuSub`, `ContextMenuSubTrigger`, `ContextMenuSubContent` - Purpose: provide right-click actions for a target region ### `Command` - Exports: `Command`, `CommandInput`, `CommandList`, `CommandEmpty`, `CommandGroup`, `CommandItem`, `CommandSeparator`, `CommandShortcut` - Purpose: command palettes, resource search, and quick navigation ### `Popover` - Exports: - `Popover` - `PopoverTrigger` - `PopoverContent` ### `Tooltip` - Exports: - `Tooltip` - `TooltipTrigger` - `TooltipContent` - `TooltipProvider` ### `Toaster` - Exports: `Toaster` - Built on: `sonner` ## Recommended Reading - [UI SDK](/en/ui-sdk-docs) - [Components](/en/ui-sdk-docs/components) - [Host Integration](/en/ui-sdk-docs/host-integration) - [UI SDK Development](/en/ui-sdk-docs/development) ### Host Integration Source: https://downcity.ai/en/ui-sdk-docs/host-integration/ This page answers a more important question: How should components in `@downcity/ui` interact with the `downcity` main package? The answer is not “import internal modules from the main package directly”. The recommended model is a three-layer split: 1. `downcity` main package exposes stable gateway capabilities 2. the host app wraps those capabilities in adapters or hooks 3. `@downcity/ui` only renders state and emits interaction intent ## Why this split is better - no dependency on `cli/city/*` internal source paths - safer production builds - UI primitives stay reusable across hosts - runtime logic, business orchestration, and presentation stay separate ## What to avoid ```tsx import { someInternalRuntimeMethod } from "../../../cli/city/src/console/ui/xxx"; ``` Avoid this pattern because it couples UI code to the source layout of the main package. ## Recommended package boundary ```text cli/city └─ exposes /api/ui/* and /api/dashboard/* gateway capabilities host app └─ wraps request / query / mutation / hook logic @downcity/ui └─ provides pure presentation primitives such as Card, Button, Sheet, Tabs ``` If reusable host adapters are needed later, add a separate package such as: ```text @downcity/ui-bridge ``` Do not merge that responsibility into `@downcity/ui`. ## The repo already has the right foundation These files already define a strong runtime boundary: - `cli/city/src/console/ui/ConsoleUIGateway.ts` - `cli/city/src/types/ConsoleUI.ts` - `cli/downcity/src/city/shared/FederationConnection.ts` - `packages/ui/src` That means the right direction is already visible: - the main package exposes stable APIs - the host app consumes them through adapters - components receive data and callbacks ## Recommended component pattern Instead of copying the business-heavy map logic from `teamprofile`, extract the interaction pattern and rebuild it for Downcity. ## `AgentWorkbenchCard` This is a selectable runtime card that shows status and exposes actions. ### It should display - agent name - runtime status - primary model - channel summary - last update time - primary actions ### It should not own - direct data fetching - API endpoint construction - main-package imports - complex domain state orchestration ## Recommended data contract ```ts export interface AgentWorkbenchSnapshot { id: string; name: string; projectRoot?: string; running: boolean; primaryModelId?: string; updatedAt?: string; baseUrl?: string; statusText?: string; channels: Array<{ channel: string; linkState?: string; statusText?: string; }>; } ``` ```ts export interface AgentWorkbenchActions { onSelect: (agentId: string) => void; onStart?: (agentId: string) => Promise | void; onStop?: (agentId: string) => Promise | void; onRestart?: (agentId: string) => Promise | void; onOpenDetails?: (agentId: string) => void; } ``` ```tsx export interface AgentWorkbenchCardProps extends AgentWorkbenchActions { snapshot: AgentWorkbenchSnapshot; selected?: boolean; busy?: boolean; } ``` The important part is simple: - the component receives a `snapshot` - the component emits `intent` - the host layer performs the real network action ## Recommended interaction flow ```text gateway returns agent list -> host app adapter maps response into snapshot -> AgentWorkbenchCard renders -> user clicks restart -> adapter calls /api/ui/agents/restart -> hook refreshes agents / overview / services -> component receives next snapshot and rerenders ``` This preserves the most valuable lesson from `teamprofile`: - the component visualizes the current state - the container orchestrates interactions - the domain layer executes commands ## Recommended host implementation In the host app, keep interaction logic inside queries, mutations, and hooks instead of moving it into UI primitives. ```tsx function AgentWorkbenchCardContainer(props: { agentId: string }) { const dashboard = useConsoleDashboard(); const snapshot = dashboard.agents.find((item) => item.id === props.agentId); if (!snapshot) return null; return ( ({ channel: item.channel || "unknown", linkState: item.linkState, statusText: item.statusText, })) || [], }} selected={dashboard.selectedAgentId === snapshot.id} onSelect={dashboard.selectAgent} onRestart={dashboard.restartAgent} onStop={dashboard.stopAgent} onOpenDetails={() => { // Host app decides whether to open a Sheet, Dialog, or inspector panel. }} /> ); } ``` ## Recommended composition This pattern works well with: - `Card` for the summary shell - `Badge` for runtime and connection states - `Button` for primary actions - `DropdownMenu` for secondary actions - `Sheet` for details - `Tabs` for overview, services, logs, and config That gives Downcity its own “overview + inspector” interaction model without importing business simulation logic from another project. ## What to borrow from `teamprofile` Borrow: - the main view plus inspector relationship - selected-state-driven detail rendering - local actions attached to the active entity - presentation driven by structured state Do not borrow: - business simulation logic inside the UI package - pathfinding and animation scheduling inside reusable primitives - UI owning domain truth ## Package ownership recommendation ### `@downcity/ui` Keep: - base primitives - layout primitives - pure presentational composites - shared styles and exported public types ### Host app Keep: - API clients - queries - mutations - page-level hooks - container components ### `cli/city` Keep: - gateway routes - runtime control - aggregated agent state - Federation types ## Best first composite components If you want to turn this design into actual reusable UI, the best first batch is: 1. `AgentWorkbenchCard` 2. `AgentChannelStatusList` 3. `AgentInspectorSheet` 4. `RuntimeMetricCard` 5. `CommandActionBar` All of them can be composed from `@downcity/ui` primitives while talking to the main package through the host adapter layer. ## Conclusion For Downcity, the correct solution is not “move a business component into the UI package”. It is: - keep reusable visuals in `@downcity/ui` - keep main-package interaction in the host app - connect them through a stable `snapshot + intent` contract If you want to keep going, the best next implementation target is `AgentWorkbenchCard + AgentInspectorSheet`. ### UI SDK Development Source: https://downcity.ai/en/ui-sdk-docs/development/ This page is for developers using `@downcity/ui` to build real screens. ## Integration Rules ```css @import "tailwindcss"; @import "@downcity/ui/source.css"; @import "@downcity/ui/styles.css"; ``` If you import `styles.css` without `source.css`, the host app may miss the Tailwind utilities used inside UI SDK components. ## Component Selection Guide ### Actions - `Button` - `Toggle` - `ToggleGroup` - `Badge` ### Forms - `Input` - `Textarea` - `Checkbox` - `Label` ### Containers - `Card` - `Tabs` - `Separator` - `Skeleton` ### Overlays - `Dialog` - `Sheet` - `DropdownMenu` - `Popover` - `Tooltip` - `Toaster` ## Common Screen Patterns ### Settings Panel ```tsx import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Separator } from "@downcity/ui"; export function ModelSettingsPanel() { return ( Model Settings
); } ``` ### Toolbar - `Button` - `Toggle` - `Tooltip` - `DropdownMenu` ### Modal Editing - `Dialog` - `DialogHeader` - `DialogTitle` - `DialogDescription` - `DialogFooter` If the content is larger, prefer `Sheet` instead of forcing everything into `Dialog`. ## Host App Conventions If a screen needs to talk to the `downcity` main package, do not import internal main-package source files from a component. See [Host Integration](/en/ui-sdk-docs/host-integration). ### `className` should mostly handle - width - layout - spacing - local page-level adjustments ### Avoid - rebuilding a second color system at the page layer - redefining buttons per business screen - overriding primitive semantics with business-specific wrappers ## Recommended Reading - [UI SDK](/en/ui-sdk-docs) - [Components](/en/ui-sdk-docs/components) - [UI SDK Modules](/en/ui-sdk-docs/modules) - [Host Integration](/en/ui-sdk-docs/host-integration) ### UI SDK Source: https://downcity.ai/zh/ui-sdk-docs/ `@downcity/ui` 是 Downcity 当前共享的 React + Tailwind 基础组件包,用来承载 Console UI 与其他宿主应用之间可复用的界面原语。 如果你想逐个查看组件文档,请继续阅读 [组件总览](/zh/ui-sdk-docs/components)。 如果你想先完成接入,再开始使用组件,请继续阅读 [安装与接入](/zh/ui-sdk-docs/installation)。 ## 包含内容 - Console 风格的基础组件:`Button`、`Badge`、`Card`、`Checkbox`、`Input`、`Label`、`Textarea`、`Separator`、`Dialog`、`Sheet`、`DropdownMenu`、`Popover`、`Tabs`、`Toggle`、`ToggleGroup`、`Tooltip`、`Skeleton`、`Toaster` - 用于合并 Tailwind 类名的 `cn` 工具 - `source.css` 与 `styles.css` 两个样式入口 - 对外公开的基础类型:`DowncityButtonVariant`、`DowncityButtonSize`、`DowncityCardSize`、`DowncityDropdownMenuItemVariant`、`DowncityToasterTheme` ## 阅读入口 - [安装与接入](/zh/ui-sdk-docs/installation) - [组件总览](/zh/ui-sdk-docs/components) - [模块总览](/zh/ui-sdk-docs/modules) - [宿主交互设计](/zh/ui-sdk-docs/host-integration) - [开发指南](/zh/ui-sdk-docs/development) ## 对外导出结构 ### 工具与类型 - `cn` - `DowncityButtonVariant` - `DowncityButtonSize` - `DowncityCardSize` - `DowncityDropdownMenuItemVariant` - `DowncityToasterTheme` ### 基础表单 - `Input` - `Textarea` - `Checkbox` - `Label` ### 容器与信息展示 - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` - `Badge` - `Separator` - `Skeleton` - `Tabs` ### 操作与状态切换 - `Button` - `Toggle` - `ToggleGroup` ### 浮层与反馈 - `Dialog` - `Sheet` - `DropdownMenu` - `Popover` - `Tooltip` - `Toaster` ## 组件范围 - 动作:`Button`、`Badge`、`Toggle`、`ToggleGroup` - 表单:`Input`、`Textarea`、`Checkbox`、`Label` - 容器:`Card`、`Separator`、`Skeleton`、`Tabs` - 浮层:`Dialog`、`DropdownMenu`、`Popover`、`Sheet`、`Tooltip`、`Toaster` ## 下一步阅读 - [安装与接入](/zh/ui-sdk-docs/installation) - [组件总览](/zh/ui-sdk-docs/components) - [宿主交互设计](/zh/ui-sdk-docs/host-integration) ### 安装与接入 Source: https://downcity.ai/zh/ui-sdk-docs/installation/ 这一页只解决一件事:如何把 `@downcity/ui` 正确接入到你的 React + Tailwind 应用里。 ## 安装包 ```bash npm install @downcity/ui ``` ## React 版本要求 `@downcity/ui` 支持 React 18.2 及 React 19。宿主应用需要让 `react` 与 `react-dom` 保持相同的主版本。 ```bash # React 18 npm install react@^18.2.0 react-dom@^18.2.0 # React 19 npm install react@^19.0.0 react-dom@^19.0.0 ``` ## 引入样式 ```css @import "tailwindcss"; @import "@downcity/ui/source.css"; @import "@downcity/ui/styles.css"; ``` 推荐顺序: 1. `tailwindcss` 2. `@downcity/ui/source.css` 3. `@downcity/ui/styles.css` 4. 你的宿主应用全局样式 ## 为什么要同时引入两个入口 ### `@downcity/ui/source.css` - 只负责声明 Tailwind 扫描来源 - 让宿主应用能拿到 UI SDK 组件内部用到的 utility class - 不注入主题色和基础层样式 ### `@downcity/ui/styles.css` - 负责主题变量和基础层 - 提供 `background`、`foreground`、`popover`、`border`、`ring` 等语义 token - 让 `bg-background`、`text-foreground`、`bg-popover` 这类类名可以直接工作 ## 最小可运行示例 ```tsx import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@downcity/ui"; export function AgentSettingsCard() { return ( Agent Settings
); } ``` ## 接入约定 - `className` 主要负责布局、宽度、间距和页面局部调整 - 优先用组件自带的 `variant`、`size` 和结构组合,不要在页面层重做一套视觉系统 - 浮层组件优先走原生组合方式,例如 `Dialog + DialogContent + DialogHeader` ## 下一步 - [组件总览](/zh/ui-sdk-docs/components) - [模块总览](/zh/ui-sdk-docs/modules) - [开发指南](/zh/ui-sdk-docs/development) ### Button Source: https://downcity.ai/zh/ui-sdk-docs/components/button/ `Button` 是 `@downcity/ui` 里最基础的动作组件。 ## 导入 ```tsx import { Button } from "@downcity/ui"; ``` ## 最小示例 ## 常用变体 ## 常用尺寸 ## 图标按钮 ## 使用建议 - 页面级主操作优先用 `size="sm"` 或 `size="default"` - 工具栏操作优先用 `variant="ghost"` - 危险操作优先用 `variant="destructive"` - 图标按钮优先用 `icon-*` 尺寸,不要手动写 `w-* h-*` ## 相关导出 - `Button` - `buttonVariants` ## 继续阅读 - [Badge](/zh/ui-sdk-docs/components/badge) - [Toggle](/zh/ui-sdk-docs/components/toggle) ### Badge Source: https://downcity.ai/zh/ui-sdk-docs/components/badge/ `Badge` 用于承载短标签、状态和轻量统计。 ## 导入 ```tsx import { Badge } from "@downcity/ui"; ``` ## 最小示例 ## 可用变体 - `default` - `secondary` - `destructive` - `outline` - `ghost` - `link` ## 常见用法 ```tsx import { Badge, Card, CardContent } from "@downcity/ui"; export function AgentCard() { return ( Research Agent Online ); } ``` ## 使用建议 - 表示状态时优先使用 `secondary` 或 `destructive` - 作为分类标签时优先使用 `outline` - 不要把 `Badge` 当作主操作按钮 ## 相关导出 - `Badge` - `badgeVariants` ## 继续阅读 - [Card](/zh/ui-sdk-docs/components/card) - [Button](/zh/ui-sdk-docs/components/button) ### Card Source: https://downcity.ai/zh/ui-sdk-docs/components/card/ `Card` 是信息容器组件组,适合承载一块结构清晰的内容。 ## 导入 ```tsx import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, } from "@downcity/ui"; ``` ## 最小示例 ## 子组件 - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` ## 尺寸 - `size="default"` - `size="sm"` ## 使用建议 - 尽量使用完整结构,不要所有内容都直接塞进 `CardContent` - 右上角辅助操作放进 `CardAction` - 底部操作区放进 `CardFooter` ## 继续阅读 - [Input](/zh/ui-sdk-docs/components/input) - [Tabs](/zh/ui-sdk-docs/components/tabs) ### Checkbox Source: https://downcity.ai/zh/ui-sdk-docs/components/checkbox/ `Checkbox` 适合布尔选择和多选列表。 ## 导入 ```tsx import { Checkbox, Label } from "@downcity/ui"; ``` ## 最小示例 ## 使用建议 - 总是配合 `Label` 使用 - 多个相关勾选项建议放进同一个 `CardContent` 或字段组 - 单个开关如果更像按钮切换,可以考虑 `Toggle` ## 继续阅读 - [Label](/zh/ui-sdk-docs/components/label) - [Toggle](/zh/ui-sdk-docs/components/toggle) ### CodeBlock Source: https://downcity.ai/zh/ui-sdk-docs/components/code-block/ `CodeBlock` 提供语言标签、横向滚动和复制操作;顶栏与代码内容之间不使用分割线。浅色模式使用 GitHub Light,暗色模式使用 One Dark Pro。传入 `code` 时使用内置轻量 token 着色;完整语法高亮应由 MDX、Markdown 或宿主构建流程提前完成,组件本身不会在浏览器加载 Shiki。 构建工具也可以把高亮后的 `children`、`data-raw` 和 `data-language` 直接传给组件。 ### Command Source: https://downcity.ai/zh/ui-sdk-docs/components/command/ `Command` 适合命令面板、资源搜索和快速跳转。它负责筛选与键盘选择,打开方式和业务执行仍由宿主管理。 `CommandItem` 必须放在 `CommandGroup` 中。需要模态命令面板时,将完整 `Command` 组合进 `Dialog`。 ### ContextMenu Source: https://downcity.ai/zh/ui-sdk-docs/components/context-menu/ `ContextMenu` 提供右键触发、键盘导航、分组、勾选项和子菜单能力。 ```tsx import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuShortcut, ContextMenuTrigger, } from "@downcity/ui"; export function FileActions() { return ( }>右键查看操作 打开 重命名 F2 ); } ``` `ContextMenuItem` 应放在 `ContextMenuGroup` 中。触发区域需要能够接收焦点,操作名称由宿主提供。 ### Dialog Source: https://downcity.ai/zh/ui-sdk-docs/components/dialog/ `Dialog` 用于需要暂时打断当前上下文的弹窗场景。 ## 导入 ```tsx import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, } from "@downcity/ui"; ``` ## 最小示例 ## 关键导出 - `Dialog` - `DialogTrigger` - `DialogPortal` - `DialogClose` - `DialogOverlay` - `DialogContent` - `DialogHeader` - `DialogFooter` - `DialogTitle` - `DialogDescription` ## 使用建议 - `DialogTitle` 不要省略 - 内容很多时优先换成 [Sheet](/zh/ui-sdk-docs/components/sheet) - 关闭按钮可通过 `showCloseButton={false}` 关闭 ## 继续阅读 - [Sheet](/zh/ui-sdk-docs/components/sheet) - [DropdownMenu](/zh/ui-sdk-docs/components/dropdown-menu) ### DropdownMenu Source: https://downcity.ai/zh/ui-sdk-docs/components/dropdown-menu/ `DropdownMenu` 适合承载一组低层级动作。 ## 导入 ```tsx import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, Button, } from "@downcity/ui"; ``` ## 最小示例 ## 可用能力 - 普通菜单项:`DropdownMenuItem` - 分组:`DropdownMenuGroup` - 标签:`DropdownMenuLabel` - 分隔线:`DropdownMenuSeparator` - 快捷键说明:`DropdownMenuShortcut` - 复选项:`DropdownMenuCheckboxItem` - 单选组:`DropdownMenuRadioGroup`、`DropdownMenuRadioItem` - 子菜单:`DropdownMenuSub`、`DropdownMenuSubTrigger`、`DropdownMenuSubContent` ## 使用建议 - 危险操作使用 `variant="destructive"` - 同类动作优先用 `DropdownMenuGroup` 分组 - 菜单里不要塞复杂表单,复杂交互请改用 `Popover` 或 `Dialog` ## 继续阅读 - [Popover](/zh/ui-sdk-docs/components/popover) - [Dialog](/zh/ui-sdk-docs/components/dialog) ### Empty Source: https://downcity.ai/zh/ui-sdk-docs/components/empty/ `Empty` 提供图形、标题、说明和操作区域,让不同页面的空状态保持一致。 操作行为、加载过程和业务文案均由宿主负责。 ### Input Source: https://downcity.ai/zh/ui-sdk-docs/components/input/ `Input` 是最常用的单行输入组件。 ## 导入 ```tsx import { Input, Label } from "@downcity/ui"; ``` ## 最小示例 ## 适合场景 - 单行文本 - 搜索框 - 模型名、路径、键值等短字段 ## 使用建议 - 配合 `Label` 使用 - 长文本请切换到 [Textarea](/zh/ui-sdk-docs/components/textarea) - 错误态优先通过原生 `aria-invalid` 传达 ## 继续阅读 - [Textarea](/zh/ui-sdk-docs/components/textarea) - [Label](/zh/ui-sdk-docs/components/label) ### FormField Source: https://downcity.ai/zh/ui-sdk-docs/components/form-field/ `FormField` 组合标签、说明、错误信息与任意控件;状态由宿主或表单库维护。 ### 基础原语 Source: https://downcity.ai/zh/ui-sdk-docs/components/foundation-primitives/ 这些组件使用现有 Base UI 运行时与 Vibecape 设计 token,不引入 Shadcn 视觉样式或运行时依赖。 ```tsx 详情内容 删除项目 DC ⌕ ``` 同时提供 `AlertDialog`、`Drawer`、`Pagination`、`RadioGroup`、`ScrollArea` 和 `Table`。 ## 高复用交互原语 `FileUpload` 只将浏览器 `File` 对象回传给宿主。`DataTable` 接收数据与列定义,排序、请求和选择状态仍由应用管理。`HoverCard` 提供延迟展示的紧凑摘要,`ResizablePanelGroup` 用于组合可调整的工作区。 ```tsx row.id} /> 导航 内容 ``` ## 颜色主题 所有原语共用同一组语义变量:`background`、`foreground`、`surface-subtle`、`control-surface`、`divider`、`interaction-hover`、`interaction-selected` 和 `primary`。宿主通过 `ThemeContainer` 设置主题,不需要修改任何组件 class。 ```tsx ``` 展示站保持页面框架不变,只用 ThemeContainer 包裹组件展示区域。内置 `neutral`、`zinc`、`slate`、`stone`、`blue` 五套预设,且每套都同时支持浅色与深色外观。 ## 主题容器 `ThemeContainer` 是整棵组件树的主题边界。它统一提供颜色、控件表面、边界、交互状态和浮层 token,具体组件不需要接收主题属性或增加主题 class。 ```tsx import { Button, Input, ThemeContainer } from "@downcity/ui"; export function ThemedApplication() { return ( ); } ``` `variant` 支持 `neutral`、`zinc`、`slate`、`stone` 和 `blue`,`mode` 支持 `light` 与 `dark`。Dropdown、ContextMenu、Select、Popover、Tooltip、HoverCard、Dialog、AlertDialog、Sheet、Drawer 与 ImagePreview 的浮层都会自动挂载到 ThemeContainer 内部,无需额外同步主题。Showcase 中每个组件使用 Neutral Light、Neutral Dark、Blue Light、Blue Dark 四个 ThemeContainer 对照块验证同一调用在不同环境中的表现。 ### ImagePreview Source: https://downcity.ai/zh/ui-sdk-docs/components/image-preview/ 支持 data URL、Blob URL 和远程 URL,不读取本地文件,也不依赖宿主桥接能力。 ### Item Source: https://downcity.ai/zh/ui-sdk-docs/components/item/ `Item` 统一图形区域、标题、说明和尾部操作的布局,但不绑定菜单或路由语义。 支持 `default`、`outline`、`muted` 变体以及 `default`、`sm` 尺寸。需要交互语义时,通过 `render={
); } ``` ## 主题模式 - `light` - `dark` - `system` ## 使用建议 - `Toaster` 通常只挂一次,放在应用根部 - 具体通知调用继续使用 `sonner` 的 `toast` - 页面组件不要重复挂多个 `Toaster` ## 继续阅读 - [Button](/zh/ui-sdk-docs/components/button) - [Installation](/zh/ui-sdk-docs/installation) ### Toaster Source: https://downcity.ai/zh/ui-sdk-docs/components/sonner/ `Toaster` wraps the `sonner` library with Downcity's theme-aware icons and styling. It provides consistent notification icons for success, info, warning, error, and loading states. ## Import ```tsx import { Toaster } from "@downcity/ui"; import "@downcity/ui/styles.css"; ``` ## Usage Place `` once at the app root. It renders a toast container that can be triggered from anywhere in the component tree. ```tsx function Layout({ children }) { return ( <> {children} ); } ``` Trigger toasts using the `sonner` toast API: ```tsx import { toast } from "sonner"; toast.success("Agent started"); toast.error("Session timeout"); toast.info("Processing..."); toast.warning("Low credits"); toast.loading("Thinking..."); ``` ## Props All standard `sonner` `ToasterProps` are supported, including: | Prop | Type | Default | Description | |------|------|---------|-------------| | `position` | `string` | `"bottom-right"` | Toast position | | `richColors` | `boolean` | `false` | Use rich colored backgrounds | | `closeButton` | `boolean` | `false` | Show close button | | `duration` | `number` | `4000` | Auto-dismiss duration in ms | ### Workboard Source: https://downcity.ai/zh/ui-sdk-docs/components/workboard/ `Workboard` renders a dashboard-style board showing all agents' status, activity, signals, and recent history. It is the primary UI for monitoring a Downcity agent fleet. ## Import ```tsx import { Workboard } from "@downcity/ui"; import "@downcity/ui/styles.css"; ``` ## Usage ```tsx function AgentDashboard() { const [board, setBoard] = useState(null); return ( setSelectedId(id)} onRefresh={() => fetchBoard()} /> ); } ``` ## Props ```tsx interface DowncityWorkboardProps { board: DowncityWorkboardBoardSnapshot | null; loading?: boolean; selectedAgentId?: string; onSelectAgent?: (agentId: string) => void; onRefresh?: () => void; className?: string; } ``` | Prop | Type | Description | |------|------|-------------| | `board` | `DowncityWorkboardBoardSnapshot` | Current board snapshot with summary and agent list | | `loading` | `boolean` | Show loading state when refreshing | | `selectedAgentId` | `string` | Currently selected agent | | `onSelectAgent` | `(id) => void` | Fired when user selects an agent | | `onRefresh` | `() => void` | Fired when user triggers refresh | ## Related types See `DowncityWorkboardBoardSnapshot`, `DowncityWorkboardAgentItem`, `DowncityWorkboardActivityItem`, `DowncityWorkboardSignalItem` from `@downcity/ui`. ### WorkboardStage Source: https://downcity.ai/zh/ui-sdk-docs/components/workboard-stage/ 并不存在公开的 `WorkboardStage` 组件。舞台层是组合式 [Workboard](./workboard) 的内部实现: 它是一个固定尺寸的坐标空间,用来摆放分区与 agent 节点。 `@downcity/ui` 不导出这个舞台组件,也不导出它的分区定义与坐标辅助函数。渲染 `Workboard` 并交由它管理舞台即可。 ## 哪些是公开的 board 的游戏几何信息只能通过一个导出的构造函数获得: ```tsx import { buildWorkboardGameMapConfig } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` `buildWorkboardGameMapConfig` 返回 `DowncityWorkboardGameMapConfig`,描述 zones、actors、 corridors、patrols 与 points of interest,也就是舞台所渲染的同一套几何信息。 ## 相关阅读 - [Workboard](./workboard):你要渲染的组合式 board - [WorkboardGame](./workboard-game):导出的游戏地图构造函数与公开游戏类型 - [WorkboardStageMap](./workboard-stage-map):atlas 内部使用的像素地图 ### WorkboardStageMap Source: https://downcity.ai/zh/ui-sdk-docs/components/workboard-stage-map/ 并不存在公开的 `WorkboardStageMap` 组件。它是组合式 [Workboard](./workboard) atlas 内部使用的 像素村庄 renderer:依据游戏地图几何信息绘制建筑、房间与路径,用来表达 agent 活动分区。 `@downcity/ui` 不导出这个 renderer。渲染 `Workboard` 并交由它绘制 atlas 即可。 ## 哪些是公开的 这张地图所绘制的几何信息来自导出的构造函数: ```tsx import { buildWorkboardGameMapConfig } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` `gameMap.zones`、`gameMap.pointsOfInterest` 与 `gameMap.actors` 就是像素地图所渲染的值。 需要复用同一套布局时,构建一次并交给自己的覆盖层使用。 ## 相关阅读 - [Workboard](./workboard):你要渲染的组合式 board - [WorkboardGame](./workboard-game):导出的游戏地图构造函数与公开游戏类型 - [WorkboardStage](./workboard-stage):atlas 背后的舞台层 ### Workboard Game Source: https://downcity.ai/zh/ui-sdk-docs/components/workboard-game/ `Workboard` 组合出游戏化的 Agent 活动地图。atlas、room、inspector 与像素精灵这些 renderer 都是这一组合的内部部件,并不是独立公开组件,因此不要单独 import 它们。 ## 游戏地图配置 `@downcity/ui` 在游戏这块只导出一个地图构造函数。传入与 board 相同的输入即可得到 `DowncityWorkboardGameMapConfig`: ```tsx import { buildWorkboardGameMapConfig, type DowncityWorkboardBoardSnapshot } from "@downcity/ui"; const gameMap = buildWorkboardGameMapConfig({ board, activeZoneId: "active", selectedAgentId, }); ``` | 选项 | 类型 | 含义 | |------|------|------| | `board` | `DowncityWorkboardBoardSnapshot` | 当前 board 快照 | | `activeZoneId` | `DowncityWorkboardZoneId` | 要聚焦的分区 | | `selectedAgentId` | `string` | 当前选中的 agent | 返回的 `DowncityWorkboardGameMapConfig` 描述 zones、actors、corridors、patrols 与 points of interest。`Workboard` 内部会自行构建它;只有在你需要复用同一套几何信息做自己的 覆盖层时,才直接调用这个构造函数。 ## 渲染游戏视图 渲染 board,并由它接管游戏模式、HUD、分区导航与 motion: ```tsx import { Workboard } from "@downcity/ui"; import "@downcity/ui/styles.css"; setSelectedId(id)} /> ``` ## 公开的游戏类型 以下类型用于配置和标注地图。atlas、room、inspector 与精灵 renderer 在内部消费它们。 | 类型 | 用途 | |------|------| | `DowncityWorkboardGameMapConfig` | 构造函数返回的完整游戏地图配置 | | `DowncityWorkboardGameZone` | 地图上的一个分区 | | `DowncityWorkboardGameActor` | 一个带位置与路线的 agent actor | | `DowncityWorkboardGameRoute` | patrol 或 corridor 路径 | | `DowncityWorkboardGamePointOfInterest` | 地图上的具名点位 | | `DowncityWorkboardGameAreaLabel` | 地图上渲染的区域标签 | ## 相关阅读 - [Workboard](./workboard):你要渲染的组合式 board - [WorkboardStage](./workboard-stage) 与 [WorkboardStageMap](./workboard-stage-map):board 内部的舞台层 ### ButtonGroup Source: https://downcity.ai/zh/ui-sdk-docs/components/button-group/ `ButtonGroup` 只负责相关按钮的排列、边界合并与首尾圆角。每个 `Button` 继续通过自己的 `variant` 表达操作层级。 `orientation` 支持 `horizontal` 和 `vertical`,默认是 `horizontal`。 ### UI SDK 模块总览 Source: https://downcity.ai/zh/ui-sdk-docs/modules/ 本页用于完整列出 `@downcity/ui` 的公开模块,方便在开发时快速确认可复用能力,而不必去翻源码。 ## 快捷键 快捷键模块提供跨平台的按键匹配、注册和 React Provider。`Mod` 在 macOS 上表示 `Command`,在 Windows/Linux 上表示 `Ctrl`。 ```tsx import { ShortcutProvider, use_register_shortcuts } from "@downcity/ui"; function AppShortcuts() { use_register_shortcuts(() => [{ id: "editor.bold", title: "加粗", keys: ["Mod+B"], scope: "editor", run: () => { document.execCommand("bold"); return true; }, }], []); return null; } ``` 支持 `Mod+B`、`Mod+L`、`Mod+I`、`Mod+,` 等组合键。快捷键动作由宿主应用注册,UI SDK 不预设业务行为。 ## 工具与类型 ### `cn` - 用途:合并 Tailwind 类名,并自动处理冲突类 - 适合场景:在组件封装或条件样式分支中安全组合 `className` ### `DowncityButtonVariant` - 可选值:`default`、`outline`、`secondary`、`ghost`、`destructive`、`link` ### `DowncityButtonSize` - 可选值:`default`、`xs`、`sm`、`lg`、`icon`、`icon-xs`、`icon-sm`、`icon-lg` ### `DowncityCardSize` - 可选值:`default`、`sm` ### `DowncityDropdownMenuItemVariant` - 可选值:`default`、`destructive` ### `DowncityContextMenuItemVariant` - 可选值:`default`、`destructive` ### `DowncityItemVariant`、`DowncityItemSize` - Item 变体:`default`、`outline`、`muted` - Item 尺寸:`default`、`sm` ### `DowncitySpinnerSize` - 可选值:`sm`、`default`、`lg` ### `DowncityToasterTheme` - 可选值:`light`、`dark`、`system` ## 样式模块 ### `@downcity/ui/source.css` - 作用:声明 Tailwind 扫描来源 - 特点:不注入主题样式 ### `@downcity/ui/styles.css` - 作用:注入 Downcity UI 设计令牌与基础层 - 特点:不负责源码扫描声明 ## 动作与状态模块 ### `Button` - 导出:`Button`、`buttonVariants` - 用途:主操作、次操作、图标按钮 ### `ButtonGroup` - 导出:`ButtonGroup` - 导出类型:`DowncityButtonGroupProps`、`DowncityButtonGroupOrientation` - 用途:组合相关按钮并合并相邻边界 ### `Badge` - 导出:`Badge`、`badgeVariants` - 用途:状态标签、分类标签、轻量统计 ### `Toggle` - 导出:`Toggle`、`toggleVariants` - 用途:单个状态开关 ### `ToggleGroup` - 导出:`ToggleGroup`、`ToggleGroupItem` - 用途:按钮组切换 ### `Spinner` - 导出:`Spinner` - 用途:按钮、列表项和异步区域的加载反馈 ### `Kbd` - 导出:`Kbd`、`KbdGroup` - 用途:展示快捷键和键位组合 ### `CodeBlock` - 导出:`CodeBlock` - 导出类型:`DowncityCodeBlockProps` - 用途:展示普通源码或构建期高亮后的代码,并提供复制操作 ### `Typography` - 导出:`H1`–`H6`、`Paragraph`、`Lead`、`Small`、`Muted`、`Strong`、`Emphasis`、`InlineCode`、`Blockquote`、`UnorderedList`、`OrderedList`、`ListItem`、`TaskListItem`、`DefinitionList`、`DefinitionTerm`、`DefinitionDescription`、`FootnoteReference`、`Footnotes`、`FootnoteItem`、`Anchor`、`Hr`、`Annotation` - 导出类型:`DowncityAnnotationProps`、`DowncityAnnotationTone` 与 Typography 属性类型 - 用途:渲染标题、正文、引用、列表、链接、行内代码和语义注释 ## 表单模块 ### `Input` - 导出:`Input` - 用途:单行输入、搜索框、短配置 ### `Textarea` - 导出:`Textarea` - 导出类型:`TextareaProps` - 用途:Prompt、长说明、多行配置 ### `Checkbox` - 导出:`Checkbox` - 用途:多选配置、权限勾选 ### `Label` - 导出:`Label` - 用途:字段标题、表单命名 ### `Select` - 导出:`Select`、`SelectTrigger`、`SelectValue`、`SelectContent`、`SelectGroup`、`SelectLabel`、`SelectItem`、`SelectSeparator` - 用途:单值选择与分组选项 ### `Slider` - 导出:`Slider` - 用途:单值或范围数值选择 ### `Switch` - 导出:`Switch` - 用途:切换单个布尔配置 ## 容器与展示模块 ### `ChatPanel` - 导出:`ChatPanel`、`ChatHistory`、`ChatMessage`、`ChatComposer`、`session_message_to_chat_message` - 导出类型:`DowncityChatPanelProps`、`DowncityChatMessage`、`DowncityChatThread`、`DowncityChatStatus`、`DowncityChatSubmitInput` - 用途:提供与运行时无关的 Agent 对话界面,包括标题栏、会话历史、消息列表、空状态、流式状态、停止生成和输入框。 - 特点:组件是受控的,不读取 Agent、Session、Electron 或网络状态;宿主通过 props 传入数据,并通过回调接管发送、停止、新建、切换和归档。 ```tsx import { ChatPanel } from "@downcity/ui"; import "@downcity/ui/styles.css"; send_message(text)} on_stop={() => stop_generation()} render_message={({ message }) => render_markdown(message.content)} /> ``` `session_message_to_chat_message` 可直接把 Session SDK canonical message 转成 Chat 展示消息。Chat UI 内置 Duobox 风格的消息、工具活动、Interaction、Changed Files、历史列表和输入区;Session 生命周期与响应操作由宿主负责。 ### `Card` - 导出: - `Card` - `CardHeader` - `CardTitle` - `CardDescription` - `CardAction` - `CardContent` - `CardFooter` ### `Separator` - 导出:`Separator` - 支持方向:`horizontal`、`vertical` ### `Skeleton` - 导出:`Skeleton` - 用途:加载占位 ### `Tabs` - 导出: - `Tabs` - `TabsList` - `TabsTrigger` - `TabsContent` - `tabsListVariants` ### `Settings` - 导出:`SettingsContainer`、`SettingSection`、`SettingGroup`、`SettingItem`、`InfoRow` - 用途:组合紧凑设置页和信息详情分组 ### `Item` - 导出:`Item`、`ItemMedia`、`ItemContent`、`ItemTitle`、`ItemDescription`、`ItemActions`、`ItemGroup`、`item_variants` - 用途:组合资源列表、搜索结果和设置入口 ### `Empty` - 导出:`Empty`、`EmptyHeader`、`EmptyMedia`、`EmptyTitle`、`EmptyDescription`、`EmptyContent` - 用途:首次使用、无数据和无筛选结果状态 ### `Workboard` - 导出:`Workboard` - 导出类型: - `DowncityWorkboardProps` - `DowncityWorkboardBoardSnapshot` - `DowncityWorkboardAgentItem` - `DowncityWorkboardActivityItem` - 用途:展示多个 agents 的像素化 game world,支持固定 40 x 24 tile 大地图画布、普通视图完整比例、全屏 cover 铺满、Smallville 风格小镇 atlas、开放式状态建筑、平铺式室内子地图、portal rail、键盘地图控制、sprite 巡游、quest path、墙面公告板、蓝图桌、codex log 和聚焦查看 ### `buildWorkboardGameMapConfig` - 导出:`buildWorkboardGameMapConfig` - 导出类型: - `DowncityWorkboardGameMapConfig` - `DowncityWorkboardGameZone` - `DowncityWorkboardGameActor` - `DowncityWorkboardGameRoute` - `DowncityWorkboardGamePointOfInterest` - `DowncityWorkboardGameAreaLabel` - `DowncityWorkboardGameAtlasProps` - `DowncityWorkboardGameRoomProps` - `DowncityWorkboardGameInspectorProps` - `DowncityWorkboardGameHudProps` - 用途:把 `workboard` 的公开快照映射成完整的游戏地图配置,方便在自定义 host 中复用 zones、actors、routes、city、station、道具和标签 ## 浮层与反馈模块 ### `Dialog` - 导出: - `Dialog` - `DialogTrigger` - `DialogPortal` - `DialogClose` - `DialogOverlay` - `DialogContent` - `DialogHeader` - `DialogFooter` - `DialogTitle` - `DialogDescription` ### `Sheet` - 导出: - `Sheet` - `SheetTrigger` - `SheetClose` - `SheetPortal` - `SheetOverlay` - `SheetContent` - `SheetHeader` - `SheetFooter` - `SheetTitle` - `SheetDescription` ### `DropdownMenu` - 导出: - `DropdownMenu` - `DropdownMenuTrigger` - `DropdownMenuPortal` - `DropdownMenuContent` - `DropdownMenuGroup` - `DropdownMenuLabel` - `DropdownMenuItem` - `DropdownMenuCheckboxItem` - `DropdownMenuRadioGroup` - `DropdownMenuRadioItem` - `DropdownMenuSeparator` - `DropdownMenuShortcut` - `DropdownMenuSub` - `DropdownMenuSubTrigger` - `DropdownMenuSubContent` ### `ContextMenu` - 导出:`ContextMenu`、`ContextMenuTrigger`、`ContextMenuContent`、`ContextMenuGroup`、`ContextMenuLabel`、`ContextMenuItem`、`ContextMenuCheckboxItem`、`ContextMenuRadioGroup`、`ContextMenuRadioItem`、`ContextMenuSeparator`、`ContextMenuShortcut`、`ContextMenuSub`、`ContextMenuSubTrigger`、`ContextMenuSubContent` - 用途:为目标区域提供右键操作菜单 ### `Command` - 导出:`Command`、`CommandInput`、`CommandList`、`CommandEmpty`、`CommandGroup`、`CommandItem`、`CommandSeparator`、`CommandShortcut` - 用途:命令面板、资源搜索和快速跳转 ### `Popover` - 导出: - `Popover` - `PopoverTrigger` - `PopoverContent` ### `Tooltip` - 导出: - `Tooltip` - `TooltipTrigger` - `TooltipContent` - `TooltipProvider` ### `Toaster` - 导出:`Toaster` - 基于:`sonner` ## 建议阅读 - [UI SDK](/zh/ui-sdk-docs) - [组件总览](/zh/ui-sdk-docs/components) - [宿主交互设计](/zh/ui-sdk-docs/host-integration) - [UI SDK 开发指南](/zh/ui-sdk-docs/development) ### 宿主交互设计 Source: https://downcity.ai/zh/ui-sdk-docs/host-integration/ 这一页回答一个更关键的问题: `@downcity/ui` 里的组件,应该怎样和 `downcity` 主包交互? 答案不是“组件里直接 import 主包内部模块”,而是把交互拆成三层: 1. `downcity` 主包负责暴露稳定的网关能力 2. 宿主应用负责把网关包装成适配器或 hook 3. `@downcity/ui` 只负责展示和触发交互意图 这样做的原因很直接: - 不依赖 `cli/city/*` 内部路径,生产打包更稳定 - 组件可以在 `console-ui`、`homepage`、其他宿主里复用 - 业务逻辑、运行时控制、视觉组件不会混成一层 ## 不推荐的方式 下面这种方式不应该继续扩散: ```tsx import { someInternalRuntimeMethod } from "../../../cli/city/src/console/ui/xxx"; ``` 这会带来几个问题: - UI 包直接依赖主包源码路径 - 打包产物无法保证对外可用 - 主包一重构,组件就失效 - 组件无法脱离当前 monorepo 复用 ## 推荐的三层结构 ```text cli/city └─ 提供 /api/ui/* 与 /api/dashboard/* 网关能力 宿主应用 └─ 封装 request / query / mutation / hook @downcity/ui └─ 提供 Button / Card / Dialog / Sheet / Tabs 等纯展示原语 ``` 如果未来需要把“交互适配器”也对外复用,可以单独增加一个包: ```text @downcity/ui-bridge ``` 但它不应该塞进 `@downcity/ui`。 ## 现有边界已经具备的基础 当前仓库里,其实已经有一条很好的边界: - `cli/city/src/console/ui/ConsoleUIGateway.ts` - `cli/city/src/types/ConsoleUI.ts` - `cli/downcity/src/city/shared/FederationConnection.ts` - `packages/ui/src` 这说明正确方向已经不是“组件直连主包源码”,而是: - 主包提供稳定 API - 前端通过适配层访问 API - 组件接收数据和回调 ## 推荐组件原型 如果参考 `teamprofile` 那种“地图 + 详情 + 操作”的组织方式,在 Downcity 里不应该直接复制它的业务模拟逻辑,而应该抽象成一个适合控制台场景的组件: ## `AgentWorkbenchCard` 这是一个“可选中、可查看状态、可触发操作”的工作区卡片。 ### 它负责展示什么 - agent 名称 - 当前运行状态 - 主模型 - 渠道状态摘要 - 最近更新时间 - 主要操作入口 ### 它不负责什么 - 自己发请求 - 自己决定 API 地址 - 自己维护复杂业务状态机 - 直接访问 `downcity` 主包源码 ## 推荐的数据契约 ```ts export interface AgentWorkbenchSnapshot { id: string; name: string; projectRoot?: string; running: boolean; primaryModelId?: string; updatedAt?: string; baseUrl?: string; statusText?: string; channels: Array<{ channel: string; linkState?: string; statusText?: string; }>; } ``` ```ts export interface AgentWorkbenchActions { onSelect: (agentId: string) => void; onStart?: (agentId: string) => Promise | void; onStop?: (agentId: string) => Promise | void; onRestart?: (agentId: string) => Promise | void; onOpenDetails?: (agentId: string) => void; } ``` ```tsx export interface AgentWorkbenchCardProps extends AgentWorkbenchActions { snapshot: AgentWorkbenchSnapshot; selected?: boolean; busy?: boolean; } ``` 关键点是: - 组件拿到的是 `snapshot` - 组件发出的是 `intent` - 真正的请求行为由宿主层实现 ## 推荐的事件流 ```text 主包网关返回 agent 列表 -> 宿主应用 adapter 转成 snapshot -> AgentWorkbenchCard 渲染 -> 用户点击 restart -> adapter 调用 /api/ui/agents/restart -> hook 刷新 agents / overview / services -> 组件收到新 snapshot 并更新 ``` 这和 `teamprofile` 最值得借鉴的地方一致: - 组件负责“可视化当前状态” - 容器负责“编排交互” - 领域层负责“真正执行命令” ## 推荐的宿主层实现 在宿主应用中,应该继续把交互收敛在 query、mutation、hook 里,而不是下沉到 UI 原语。 ```tsx function AgentWorkbenchCardContainer(props: { agentId: string }) { const dashboard = useConsoleDashboard(); const snapshot = dashboard.agents.find((item) => item.id === props.agentId); if (!snapshot) return null; return ( ({ channel: item.channel || "unknown", linkState: item.linkState, statusText: item.statusText, })) || [], }} selected={dashboard.selectedAgentId === snapshot.id} onSelect={dashboard.selectAgent} onRestart={dashboard.restartAgent} onStop={dashboard.stopAgent} onOpenDetails={() => { // 由宿主决定是打开 Sheet、Dialog 还是右侧 Inspector }} /> ); } ``` ## 推荐的页面组合 这个组件适合和以下原语组合: - `Card`:承载 agent 摘要 - `Badge`:展示运行态、连接态 - `Button`:主操作,例如启动、停止、重启 - `DropdownMenu`:收纳次级动作 - `Sheet`:打开详情面板 - `Tabs`:在详情面板里切换概览、服务、日志、配置 组合之后,就是 Downcity 版的“状态地图 + 检查器”模式,只是从 `teamprofile` 的空间地图,变成了控制台里的运行时工作区。 ## 从 `teamprofile` 应该借鉴什么 应该借鉴: - 主视图 + Inspector 的双栏关系 - 选中态驱动详情面板 - 操作入口靠近当前对象 - 视觉层只消费结构化状态 不应该借鉴: - 业务模拟逻辑直接进 UI 包 - pathfinding、动画调度、主题状态全部塞进一个组件 - UI 组件自己持有领域真相 ## 对包边界的建议 ### `@downcity/ui` 只放这些内容: - 基础组件 - 通用布局原语 - 纯展示型复合组件 - 公共样式与公开类型 ### 宿主应用 放这些内容: - API clients - queries - mutations - 页面级 hook - 容器组件 ### `cli/city` 放这些内容: - 网关路由 - runtime 控制能力 - agent 状态聚合 - 类型定义 ## 第一批最值得做的复合组件 如果你要把这套设计真正落到 `downcity-ui`,我建议先做下面这几类,而不是直接做“业务地图”: 1. `AgentWorkbenchCard` 2. `AgentChannelStatusList` 3. `AgentInspectorSheet` 4. `RuntimeMetricCard` 5. `CommandActionBar` 它们都可以复用 `@downcity/ui` 原语,但和主包交互时统一通过宿主适配层完成。 ## 结论 对 Downcity 来说,正确方案不是“把某个业务组件搬进 UI 包”,而是: - 在 `@downcity/ui` 中沉淀可复用视觉组件 - 在宿主应用中承接 `downcity` 主包交互 - 通过稳定的 `snapshot + intent` 契约连接两者 如果后面你要继续推进,我建议下一步直接做 `AgentWorkbenchCard + AgentInspectorSheet` 这一组,这是最贴近当前 `downcity` 运行时场景的一套组件。 ### UI SDK 开发指南 Source: https://downcity.ai/zh/ui-sdk-docs/development/ 本页面面向实际使用 `@downcity/ui` 构建页面的开发者。 ## 接入原则 ```css @import "tailwindcss"; @import "@downcity/ui/source.css"; @import "@downcity/ui/styles.css"; ``` 如果只引入 `styles.css` 而没有引入 `source.css`,宿主应用可能拿不到 UI SDK 组件内部使用的 Tailwind utility。 ## 组件选择建议 ### 动作 - 主操作:`Button` - 工具栏状态按钮:`Toggle` - 互斥切换栏:`ToggleGroup` - 轻量状态标记:`Badge` ### 表单 - 单行输入:`Input` - 多行输入:`Textarea` - 布尔勾选:`Checkbox` - 字段标题:`Label` ### 容器 - 结构化信息块:`Card` - 同层级内容切换:`Tabs` - 内容分隔:`Separator` - 加载占位:`Skeleton` ### 浮层 - 阻断式弹窗:`Dialog` - 侧边抽屉:`Sheet` - 菜单动作:`DropdownMenu` - 轻量说明面板:`Popover` - 极短提示:`Tooltip` - 全局通知:`Toaster` ## 常见页面组合 ### 设置面板 ```tsx import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Separator } from "@downcity/ui"; export function ModelSettingsPanel() { return ( Model Settings
); } ``` ### 工具栏 - `Button` - `Toggle` - `Tooltip` - `DropdownMenu` ### 模态编辑 - `Dialog` - `DialogHeader` - `DialogTitle` - `DialogDescription` - `DialogFooter` 如果内容很多,优先考虑 `Sheet`,不要把所有复杂编辑都塞进 `Dialog`。 ## 宿主应用约定 如果页面需要和 `downcity` 主包交互,不要在组件里直接导入主包源码路径。请阅读 [宿主交互设计](/zh/ui-sdk-docs/host-integration)。 ### `className` 主要负责 - 宽度 - 布局 - 间距 - 页面局部补充样式 ### 不推荐 - 在页面层重做一套颜色系统 - 为单个业务页面重做按钮体系 - 用业务组件覆盖 UI SDK 原语的基本语义 ## 建议阅读 - [UI SDK](/zh/ui-sdk-docs) - [组件总览](/zh/ui-sdk-docs/components) - [UI SDK 模块总览](/zh/ui-sdk-docs/modules) - [宿主交互设计](/zh/ui-sdk-docs/host-integration) ## Blog ### What Is an Agent Harness? Source: https://downcity.ai/en/blog/what-is-an-agent-harness/ An agent harness is the runtime layer that turns a language model into a working agent. The model reasons. The harness does everything else. It runs the loop (call the model, execute a tool, feed the result back), keeps session state, gives the agent a workspace and the tools to act on it, and enforces the boundaries that keep those actions safe and reviewable. That split explains a pattern you see once agents leave the demo stage. Most failures are not reasoning failures. They are runtime failures. Context is lost between runs. A tool call reaches something it should not have reached. Nobody can reconstruct what happened. The agent cannot resume yesterday's work. ## The four layers people mix up These terms get used interchangeably, but they are different layers with different jobs. | Layer | What it is | What it does not do | | --- | --- | --- | | Model | The reasoning engine | It has no memory, no tools, and no persistence | | Framework | A library for expressing agent logic | It does not own execution, state, or governance | | Harness | The runtime that executes and governs the agent | It does not decide your product's interface | | Protocol (MCP) | A contract for connecting tools and data | It is not a runtime and does not manage sessions | A useful test: if you delete it, what breaks? Delete the model and nothing reasons. Delete the framework and you write more code, but the agent can still run. Delete the harness and the agent stops existing between requests, because the thing that was holding its state, its tools, and its permissions is gone. ## What a harness actually owns The list is short but every item is load-bearing. In practice a harness is responsible for: - **The execution loop.** Calling the model, parsing tool calls, running them, and feeding results back until the task finishes or a limit is hit. - **Session state.** The conversation, the working context, and the ability to resume, fork, or replay a session rather than starting over. - **A workspace.** The files, directories, and shell the agent operates on. An agent with no bounded workspace cannot do real work. - **Tools and plugins.** What the agent can actually call, mounted explicitly rather than discovered by accident. - **Memory.** What survives a single session, and how it is retrieved later. - **Permissions and sandboxing.** Which actions are allowed, which need approval, and where the hard boundary sits. - **Services.** Model access, identity, storage, and anything else the agent needs from the outside world. - **Usage and cost.** Which model was called, how many tokens were spent, and who pays for it. - **Observability.** Logs, traces, and artifacts good enough to answer "what did it do, and why?" Notice how little of that is about prompting. Harness work is mostly systems work: process boundaries, state machines, permissions, cost accounting. ## How a harness differs from a framework A framework helps you describe an agent. A harness runs one. Frameworks are good at composition: chains, graphs, tool definitions, prompt templates. They are libraries, which means your application still owns the process, the persistence layer, the permission model, and the deployment story. When people say an agent "worked in the notebook but not in production," the gap is usually exactly that missing layer. The library was doing its job. Nothing was doing the runtime's job. ## How a harness relates to MCP MCP (Model Context Protocol) is a connection standard. It answers one question well: how does an agent reach a tool or a data source in a predictable way? That is genuinely useful, and it is not the same job as a harness. MCP defines the contract at the edge of the agent. The harness is what holds the agent itself: its loop, its session, its workspace, its permissions, its accounting. They compose rather than compete. A harness can expose MCP servers as tools. MCP by itself will not tell you which session called which tool, with whose credentials, at what cost. That is harness territory. ## Why production raises the bar Four requirements appear the moment real users depend on the agent. **Resumability.** A demo runs for ninety seconds in one process. A real agent runs for weeks across restarts and deploys. State has to outlive the process, which means sessions must be durable objects rather than in-memory conveniences. **Boundaries.** Unbounded tool access is fine when the only user is you. With real users you need an explicit answer to what the agent may touch, what needs human approval, and where the sandbox edge is. **Multiple products, one backend.** Most teams do not ship one agent. They ship a console, an extension, an API, and an internal tool, all needing the same models, accounts, and usage records. Rebuilding that per surface is how agent projects quietly die. **Cost visibility.** Token spend is a real line item. If you cannot attribute usage to a user, a product, or a session, you cannot price, throttle, or debug it. None of these are model problems. All of them are harness problems. ## How Downcity maps to these concerns Downcity is an open-source agent harness plus the kits that make an agent shippable. The mapping from responsibility to component is deliberately one-to-one: | Harness responsibility | Downcity component | | --- | --- | | Execution loop, sessions, workspace | [Agent Harness](/en/docs/agent/overview/) and the [agent runtime](/features/) | | Local agent hosting and request routing | [City](/en/city-sdk-docs/) | | Model catalog, service routing, identity | [Federation](/en/docs/federation/overview/) | | Embedding agents in your own app | [Agent SDK](/en/agent-sdk-docs/) | | Tools, memory, shell, tasks | [Agent Plugins](/en/powers-docs/) | | Usage, credits, and payment | [Accounts and Payments](/en/payments/) | | Agent-facing UI patterns | [UI SDK](/product/ui-sdk/) | Two design choices are worth calling out because they shape everything else. First, **capabilities are mounted explicitly**. The caller decides which plugins and tools an agent gets, instead of relying on automatic assembly. Explicit mounting is what makes permissions auditable. Second, **the product boundary is kept separate from the runtime**. Products own the user experience. Agents execute inside explicit boundaries. The runtime owns the repeated infrastructure: models, tools, tasks, memory, permissions, usage, billing, and observability. You can read the longer argument in the [whitepaper](/whitepaper/). ## Common questions **Is an agent harness the same as an agent framework?** No. A framework is a library for describing agent logic. A harness is the runtime that executes it, holds its state, and governs what it can do. Most teams end up needing both. **Do I need a harness if I already use MCP?** If your agent only needs to call a few tools once, MCP may be enough. Once you need durable sessions, permissions, cost attribution, or multiple products sharing one backend, you need a runtime, and that is the harness. **Is a harness only for coding agents?** No. Coding agents made the term popular because they stress every runtime requirement at once: long sessions, file access, shell execution, approvals. The same requirements apply to research, operations, and support agents. **Can I build one myself?** You can, and many teams start there. The honest tradeoff is that you will end up reimplementing session persistence, sandboxing, permission checks, usage accounting, and multi-surface access. [Downcity](/start/) is open source if you would rather start from an existing one. **What does "open source agent harness" mean in practice?** It means the runtime is inspectable and self-hostable, and the license permits commercial use. For Downcity specifically, the runtime and kits live in the [public repository](https://github.com/genesiscosmos/downcity) under Apache 2.0. ## Where to go next If you want to see a harness running before reading more theory, [install the CLI and start your first agent](/start/). It takes a few minutes and puts a real session, workspace, and tool boundary in front of you. If you would rather understand the architecture first, the [production agents whitepaper](/whitepaper/) explains why the harness layer exists and where its boundaries belong. ### The Anatomy of an Agent Harness Source: https://downcity.ai/en/blog/the-anatomy-of-an-agent-harness/ [What an agent harness is](/en/blog/what-is-an-agent-harness/) is one question. Where each responsibility physically lives is another. This piece answers the second one. The useful way to study a harness is not a feature list but a request. Follow a single task from the moment a user submits it to the moment the agent stops, and every subsystem it touches becomes visible, along with the order it touches them. ## The whole shape first ```mermaid flowchart TB IN["Product surface
console, extension, API"] --> LOOP["Execution loop"] LOOP --> CTX["Context assembly"] CTX --> GW["Model gateway"] GW --> MODEL[("Model provider")] GW --> LOOP LOOP --> REG["Tool registry"] REG --> GATE["Permission gate"] GATE --> BOX["Sandbox"] BOX --> WS["Workspace
files, shell, git"] WS --> LOOP CTX <--> MEM["Memory"] LOOP <--> SESS[("Session store")] LOOP --> EVT["Event stream"] SESS --> EVT LOOP --> LEDGER[("Usage ledger")] ``` The loop sits in the middle because everything else is either feeding it or recording what it did. Read the diagram as three rings: the loop is the control flow, the gateway and registry are what it reaches outward through, and the session store, memory, ledger and event stream are what remembers. ## One request, end to end Here is the same shape as a sequence, which is usually easier to reason about than a diagram. 1. **A task arrives** from a product surface: a console, a browser extension, an API call, a scheduled job. 2. **The harness opens or resumes a session.** If this is a continuation, it loads prior state instead of starting fresh. 3. **Context is assembled.** The harness decides what actually goes into the prompt: system instructions, recent turns, retrieved memories, tool schemas, workspace state. This is a budget decision, not a concatenation. 4. **The model is called** through the gateway, which handles provider selection, streaming, and retries. 5. **The response comes back.** Either it is a final answer, or it contains one or more tool calls. 6. **Tool calls pass the permission gate.** Allowed, denied, or held for human approval. 7. **Approved calls execute inside the sandbox**, against the workspace. 8. **Results are appended to the session** and folded back into context. The loop returns to step 3. 9. **Throughout, events are emitted and usage is recorded**, so the run can be observed, replayed, and billed. Steps 3 through 8 repeat until the task finishes or a limit is reached. Everything below is what one of those steps actually requires. ## The subsystems, one at a time ### 1. The execution loop The loop is the only part that is strictly mandatory. It calls the model, dispatches tool calls, appends results, and decides when to stop. The interesting engineering is in the stopping conditions. A loop needs a turn ceiling, a wall-clock budget, a token budget, and a way to detect that it is repeating itself. Without those, a confused agent runs until someone notices the bill. ### 2. The model gateway A thin provider abstraction, but only if you build it deliberately. The gateway owns model selection, credential handling, streaming, and retry behavior. The failure mode it prevents: provider specifics leaking into the loop. Once `if provider == "x"` appears in your control flow, swapping models becomes a refactor instead of a config change. It also becomes the natural place to normalize token accounting, because providers report usage differently. ### 3. The session store A session is the durable record of a conversation and its working state. In a demo it is an array in memory. In production it is a persisted object with an identity. What separates a real session store from a chat log is what it supports: **resume** after a process restart, **fork** to explore an alternative without losing the original, and **replay** to reconstruct what happened. All three require that the session, not the process, be the source of truth. This is the subsystem most often underestimated. Teams usually discover they need it after a deploy kills a long-running task. ### 4. Context assembly Context assembly decides what the model sees on each turn. It has the largest effect on output quality and is the easiest subsystem to get wrong. Three jobs live here. **Selection**: which instructions, memories, and tool schemas are relevant right now. **Budgeting**: how to fit them into a finite context window. **Compaction**: what to summarize or drop when the history outgrows the window. Dumping the full transcript into every call works until it does not, and when it stops working, the symptom is not an error. It is a gradual quality decline that is hard to attribute. ### 5. The workspace The workspace is what the agent can actually touch: a directory tree, a shell, and usually version control. It is the difference between an agent that talks about work and one that does it. Its design question is where the boundary sits. A workspace scoped to one repository is safer and simpler. A workspace that can reach the host filesystem is more capable and much harder to reason about. ### 6. The tool registry The registry is the inventory of what the agent can call. The design choice that matters is **explicit mounting** versus automatic discovery. Automatic assembly feels convenient and undermines everything downstream. If tools appear without a deliberate decision, then permissions cannot be audited, because there is no point in the code where someone decided what this agent may do. Explicit mounting gives you that decision point, and gives the permission gate something stable to validate against. Each tool also needs a schema the model can read and the harness can validate. A tool that returns malformed output is worse than a missing tool, because the model will try to use it. ### 7. The permission gate and the sandbox These are two different mechanisms and are often conflated. The **permission gate** is policy: this tool is allowed, this one requires approval, this one is denied in this context. It operates before execution and produces a decision plus an audit record. The **sandbox** is enforcement: the process boundary that makes the policy real. If the gate says an agent may not write outside its workspace, the sandbox is what makes that true when the agent tries anyway. A gate without a sandbox is a suggestion. A sandbox without a gate is a fence with no rules about who climbs it. You want both, and you want the human-in-the-loop approval path to run through the gate rather than beside it. ### 8. Memory Memory is what survives a session. It is distinct from session state, which survives a process but belongs to one conversation. The hard parts are not storage. They are deciding what is worth persisting, when to retrieve it, and how to avoid retrieving something that was true last month and is misleading now. Memory that is written but never correctly retrieved is pure cost. ### 9. The usage ledger Every model call has a cost, and the ledger is where it is attributed: to a session, a user, a product, a tenant. This subsystem tends to be added late and then suddenly become urgent, usually when someone asks why last month's infrastructure bill tripled. Retrofitting attribution is painful because the information has to be captured at call time, at the gateway, where the token counts actually exist. ### 10. The event stream The event stream is the record of what happened: tool calls, approvals, errors, artifacts, state transitions. It is what makes a run **explainable** rather than merely repeatable. Two consumers depend on it. Humans debugging a surprising outcome, and the usage ledger reconciling cost against activity. If events are emitted ad hoc from scattered call sites, both consumers get an incomplete picture. ## What breaks when a subsystem is missing This is the practical payoff. Production symptoms map fairly reliably onto missing organs. | Symptom you observe | Likely missing subsystem | | --- | --- | | Agent cannot resume after a deploy | Session store is in-memory, not durable | | Output quality degrades over long sessions | Context assembly has no compaction strategy | | Nobody can explain a bad outcome | Event stream is incomplete or unstructured | | Costs are unaccounted for per customer | Usage ledger is absent or retrofitted | | Tool access cannot be audited | Tools are auto-discovered, no explicit mounting | | An agent acted outside its intended scope | Permission gate exists but the sandbox does not enforce it | | Retrying a call duplicates side effects | Loop has no idempotency handling around tool execution | | A run never terminates | No turn, time, or token ceiling on the loop | | The same context is retrieved repeatedly and ignored | Memory is written without a retrieval strategy | | Swapping models requires a code change | Provider logic leaked out of the gateway | Read the table backwards when you are designing. Each symptom is a requirement you are choosing to satisfy now or to discover later. ## Building it yourself versus adopting one The honest framing is that a harness is not hard to start and hard to finish. The first version, a loop with a model call and a few tools, is a weekend. The subsystems above are where the following months go. The build-versus-adopt decision usually comes down to which of these you consider core to your product. Session durability, sandboxing, permission policy, and cost attribution are infrastructure. They are rarely what your users are paying you for, and they are all things you can get wrong in ways that are expensive after real users arrive. ## How Downcity's anatomy maps to this list Downcity was designed against this decomposition, so the mapping is close to one to one. | Subsystem | Downcity component | | --- | --- | | Execution loop, session, workspace | [Agent Harness](/en/docs/agent/overview/) | | Session lifecycle and resumption | [Agent lifecycle](/en/docs/agent/lifecycle/) | | Model selection and provider access | [Federation](/en/docs/federation/overview/) and the [City host](/en/city-sdk-docs/) | | Tool registry, members, and plugins | [Agent Plugins](/en/powers-docs/) | | Memory as a mounted capability | [memory plugin](/en/docs/power/overview/) | | Permission gate and approval flow | [Permissions](/en/docs/security/permissions/) | | Sandbox and data boundaries | [Security overview](/en/docs/security/overview/) | | Usage, credits, and payment | [Accounts and Payments](/en/payments/) | | Local CLI operation and debugging | [CLI](/en/docs/cli/overview/) | | Embedding the runtime in your product | [Agent SDK](/en/agent-sdk-docs/) | Three choices in that table are worth flagging, because they are where Downcity takes a position. **Tools are mounted explicitly**, so the permission gate always has a deliberate decision to validate against. **The permission gate and the sandbox are separate mechanisms**, so policy and enforcement can be reasoned about independently. And **usage accounting lives at the model boundary**, which is the only place where the token counts needed for attribution actually exist. ## Common questions **Is the execution loop the same as an agent framework?** No. Frameworks help you describe agent behavior. The loop is the runtime that executes it. You can write the loop using a framework, or without one. **How many sessions should a harness support per agent?** As many as your product needs, which is why sessions must be addressable objects rather than process globals. Multi-tenant products typically need one session store with per-tenant isolation. **Does context assembly mean retrieval-augmented generation?** RAG is one input to context assembly. Assembly also covers system instructions, tool schemas, workspace state, and deciding what to drop when the budget runs out. **Why separate the permission gate from the sandbox?** Because they answer different questions. The gate answers "should this be allowed," producing a policy decision and an audit trail. The sandbox answers "can this physically happen," producing enforcement. Merging them makes both harder to test. **Where should usage accounting be recorded?** At the model gateway, when the call happens. That is the point where provider-reported token counts exist. Reconstructing them later from logs is approximate at best. **Can I add these subsystems incrementally?** Yes, and most teams do. The practical ordering is session durability first, then permissions and sandboxing, then usage attribution. Those three are the ones that are painful to retrofit. ## Where to go next If the anatomy makes sense and you want to see it running, [start your first agent](/start/) and watch a real session, workspace, and tool boundary in action. The [CLI documentation](/en/docs/cli/overview/) covers the commands you will use to inspect state. If you are evaluating this against your own architecture, the [whitepaper](/whitepaper/) covers the design rationale for where these boundaries belong, and the [features page](/features/) lists what each subsystem covers in practice. ### Agent Harness vs MCP: Different Layers, Not Rivals Source: https://downcity.ai/en/blog/agent-harness-vs-mcp/ The comparison comes up constantly, usually phrased as a choice: should I use MCP, or do I need a harness? It is the wrong question. MCP and a harness operate at different layers and do different jobs. Asking which one to use is like asking whether you need HTTP or a server. The answer is that they exist for different reasons and you will likely have both. ```mermaid flowchart LR subgraph H["Agent harness"] LOOP["Execution loop"] --> SESS[("Session")] LOOP --> GATE["Permission gate"] end GATE --> M1["MCP client"] GATE --> T2["Native tool"] M1 --> S1["MCP server A"] M1 --> S2["MCP server B"] ``` A harness owns the agent: its loop, its session, its permissions. MCP sits at the edge, describing how the agent reaches a tool. The boundary is where the two meet. ## What MCP actually defines MCP (Model Context Protocol) is a connection standard. It specifies how an agent discovers and calls external capabilities: tools, resources, and prompts. What it standardizes is genuinely useful. Before MCP, every integration looked different, and every agent framework had its own conventions. MCP replaced a pile of bespoke connectors with one contract, which is why it spread quickly. What it deliberately does not cover is just as important. MCP does not define: - how long an agent runs, or what happens across restarts - what an agent is allowed to do, and who approves the exceptions - which session made a call, and with whose credentials - how much the call cost, and who pays for it - where the agent's workspace lives Those questions are all outside the protocol, because they belong to the runtime. ## What a harness owns The harness is the runtime that executes the agent. It runs the loop, holds session state, gives the agent a workspace, mounts its tools, enforces permissions, and records usage. The part worth pausing on is that a tool is a tool. Whether a capability arrives over MCP or is compiled into the process, the harness still has to decide whether to mount it, whether to allow it, and what to record. MCP changes how a tool connects, not what the runtime must do about it. ## Why they get confused Three reasons, and all of them are reasonable at first glance. **They both talk about tools.** MCP standardizes tool access. A harness also concerns itself with tools. Same noun, different verb: MCP describes the connection, the harness governs the call. **They arrived around the same time.** Teams adopting agents in the same period met both terms together, often in the same README, and assumed they were alternatives. **Some agent frameworks bundle a bit of both.** A library that offers tool calling and also has an MCP client can look like a runtime. It becomes clear that it is not one the first time a task needs to survive a restart. ## Side by side | Question | MCP | Agent harness | | --- | --- | --- | | What layer? | Protocol at the agent's edge | Runtime around the agent | | Primary job | Describe how to reach a tool | Execute and govern the agent | | Owns sessions? | No | Yes | | Owns permissions? | No | Yes | | Owns cost accounting? | No | Yes | | Standardized? | Yes, one shared contract | No, each implementation differs | | Choose one? | No | No | That last row is the point of this article. ## How they compose in practice A concrete chain, in order, for one tool call that arrives over MCP: 1. **The harness assembles context** for the turn, including the schemas of the tools this agent may use. 2. **The model emits a tool call** naming one of those tools. 3. **The harness's permission gate evaluates it.** Allow, deny, or hold for approval. MCP has no opinion here; this is policy. 4. **The harness's MCP client performs the call** against the configured server. 5. **The server does the work** and returns a result. 6. **The harness records the event and the cost**, and appends the result to the session. 7. **The loop continues** with the updated context. MCP contributes step 4 and part of step 5. The harness owns everything else. Read the list again and notice how much of it is governance rather than connectivity, which is exactly the split the comparison misses. ## Where the boundary shows up Two situations make the division obvious. **Auditing.** Someone asks which tools an agent could reach last quarter, and who approved them. MCP cannot answer, because it has no concept of an agent's configuration over time. The harness can, because mounting is a decision it made and recorded. **Multi-tenancy.** Two customers connect to the same MCP server with different credentials and different budgets. Nothing in the protocol distinguishes them. Sessions, identity, and usage attribution are runtime concerns. ## Do you need MCP? Not necessarily. If an agent only needs a handful of stable capabilities, native tools are simpler: no server to run, no network hop, no schema negotiation at the edge. MCP earns its place when you want a capability to be reachable by more than one agent, or when third parties publish tools you would rather not reimplement. The protocol's value scales with the number of consumers, which is why it matters more as your setup grows. ## Do you need a harness? If the agent runs once and exits, in a single process, for you alone, then a loop with a model call may genuinely be enough. The moment any of those conditions breaks, you need the runtime. That means durable sessions when a task outlives a process, a permission model once other people's work is at stake, and cost attribution once someone else pays the bill. None of these are provided by a protocol. ## How Downcity handles MCP Downcity's position follows from the boundary: MCP is treated as one source of tools, not as the runtime. Concretely, MCP servers are mounted like any other capability. The harness owns the loop, session, permission gate, and usage ledger; an MCP server supplies a tool at the edge. That means an MCP-backed tool passes the same permission gate and lands in the same audit trail as a native one, which is what you want and what a protocol alone cannot give you. - [Agent Plugins](/en/powers-docs/) covers mounting tools and plugins. - [Permissions](/en/docs/security/permissions/) covers the gate every tool call passes through. - [Agent overview](/en/docs/agent/overview/) covers the loop and session model. ## Common questions **Is MCP a replacement for an agent framework?** No. A framework helps you describe agent behavior; MCP describes tool connectivity. They are different kinds of thing, and most projects use both. **Can I use MCP without a harness?** For a short-lived, single-process, single-user agent, yes. You lose session durability, permissions, and cost attribution, which may be fine and may become urgent later. **Does a harness have to use MCP?** No. Native tools avoid the extra hop and are simpler to debug. MCP is worth the overhead when the capability needs to be shared or comes from a third party. **Which one should I adopt first?** Start with the layer that hurts first. If tools are the problem, adopt MCP. If sessions, permissions, or costs are the problem, you need a harness, and MCP can come later. **Are MCP servers secure?** A server is code you are choosing to run, so it deserves the same scrutiny as any dependency. The harness's sandbox and permission gate limit what a call can reach, which is the point of keeping those in the runtime. **Does MCP replace APIs?** Not exactly. It standardizes how agents reach capabilities that may themselves be backed by APIs. It is a contract for agent access, not a replacement for the systems behind it. ## Where to go next If you want to see the boundary in working code, [start an agent](/start/) and mount a tool, then look at the permission record it produces. The [whitepaper](/whitepaper/) argues the case for keeping governance in the runtime rather than in a protocol. If you are still forming the mental model, [what an agent harness is](/en/blog/what-is-an-agent-harness/) covers the definition, and [the anatomy](/en/blog/the-anatomy-of-an-agent-harness/) walks through each subsystem the runtime owns. ### Agent Harness 是什么?一次说清运行时、Session 与权限边界 Source: https://downcity.ai/zh/blog/what-is-an-agent-harness/ Agent Harness 是把大模型变成「可用 Agent」的那一层运行时。 模型负责推理,Harness 负责其他所有事:跑执行循环(调用模型、执行工具、把结果喂回去)、维护 Session 状态、给 Agent 一块可操作的工作区、装配它能调用的工具,并守住权限与沙箱边界。 理解了这条分界线,就能解释一个很常见的现象:Agent 一旦离开 demo 阶段,出问题的地方往往不是推理,而是运行时。上下文在两次运行之间丢了;工具调用碰到了不该碰的东西;没人能还原当时发生了什么;Agent 没法接着昨天的工作往下做。 ## 容易混淆的四层 这几个词经常被混着用,但它们处在不同层,职责也不同。 | 层 | 它是什么 | 它不负责什么 | | --- | --- | --- | | 模型 | 推理引擎 | 没有记忆、没有工具、不能持久化 | | 框架 | 用来表达 Agent 逻辑的库 | 不管执行、状态和治理 | | Harness | 真正执行并约束 Agent 的运行时 | 不决定你产品的交互界面 | | 协议(MCP) | 连接工具与数据的约定 | 它不是运行时,也不管 Session | 一个简单的判断方法:把它删掉,什么会坏? 删掉模型,没东西会推理。删掉框架,你要多写些代码,但 Agent 还能跑。删掉 Harness,Agent 在两次请求之间就不存在了,因为持有它状态、工具和权限的东西没了。 ## Harness 到底负责什么 清单不长,但每一项都缺不得: - **执行循环**:调用模型、解析工具调用、执行、把结果喂回,直到任务完成或触到上限。 - **Session 状态**:对话、工作上下文,以及继续、分叉、重放一个 Session 的能力,而不是每次从头开始。 - **Workspace**:Agent 能操作的文件、目录与 Shell。没有边界清晰的工作区,Agent 做不了真活。 - **工具与 Plugin**:Agent 实际能调用的东西,且是显式装配的,不是碰巧发现的。 - **记忆**:什么能跨 Session 存活,以及后续怎么检索出来。 - **权限与沙箱**:哪些动作允许、哪些需要审批、硬边界画在哪里。 - **服务**:模型访问、身份、存储,以及 Agent 需要的外部能力。 - **用量与成本**:调了哪个模型、花了多少 token、由谁买单。 - **可观测性**:日志、链路与产物,要足以回答「它做了什么,为什么这么做」。 注意这里面几乎没有「提示词」的份量。Harness 的工作主要是系统工程:进程边界、状态机、权限、成本核算。 ## 和 Agent 框架的区别 框架帮你**描述**一个 Agent,Harness 帮你**运行**一个 Agent。 框架擅长编排:链、图、工具定义、提示模板。但它是库,所以进程、持久化、权限模型和部署方式仍然归你的应用管。 当有人说「Agent 在 notebook 里好好的,上生产就不行」,缺的通常就是这一层。库在履行自己的职责,只是没有任何东西在履行运行时的职责。 ## 和 MCP 的关系 MCP(Model Context Protocol)是连接标准。它把一个具体问题回答得很好:Agent 如何以可预期的方式访问某个工具或数据源? 这很有价值,但它和 Harness 不是同一件事。MCP 定义的是 Agent 边缘的契约;Harness 持有的是 Agent 本身:它的循环、Session、Workspace、权限与计费。 两者是组合关系,不是竞争关系。Harness 可以把 MCP server 作为工具暴露给 Agent。但 MCP 本身不会告诉你:哪个 Session 在什么成本下、用谁的凭证调用了哪个工具。那是 Harness 的领地。 ## 为什么进入生产后要求会突然变高 一旦有真实用户依赖这个 Agent,四个要求会立刻出现。 **可续跑。** demo 在一个进程里跑九十秒。真实 Agent 要跨重启、跨部署跑几周。状态必须活得比进程久,Session 也就必须是持久化对象,而不是内存里的便利结构。 **边界。** 只给自己用的时候,工具权限放开无所谓。面对真实用户,你必须能明确回答:Agent 可以碰什么、什么需要人工审批、沙箱边缘在哪里。 **多个产品共用一套后端。** 多数团队要交付的不是一个 Agent,而是控制台、扩展、API 和内部工具,它们都需要同一套模型、账号与用量记录。每个入口各重建一遍,正是 Agent 项目悄悄死掉的方式。 **成本可见。** token 开销是真实的成本项。如果用量无法归因到某个用户、产品或 Session,你就没法定价、限流或排查。 这些都不是模型问题,全部是 Harness 问题。 ## Downcity 与这些职责的对应关系 Downcity 是一套开源 Agent Harness,以及让它可交付的配套 Kit。职责到组件的映射是刻意的,基本一一对应: | Harness 职责 | Downcity 组件 | | --- | --- | | 执行循环、Session、Workspace | [Agent Harness](/zh/docs/agent/overview/) 与 [Agent 运行时](/zh/features/) | | 本地 Agent 宿主与请求转发 | [City](/zh/city-sdk-docs/) | | 模型目录、Service 路由、身份 | [Federation](/zh/docs/federation/overview/) | | 把 Agent 嵌进你自己的应用 | [Agent SDK](/zh/agent-sdk-docs/) | | 工具、记忆、Shell、任务 | [Agent Plugins](/zh/powers-docs/) | | 用量、Credits 与支付 | [账号与支付](/zh/payments/) | | 面向 Agent 的 UI 模式 | [UI SDK](/zh/product/ui-sdk/) | 有两个设计选择值得单独指出,因为它们决定了其他一切。 第一,**能力显式装配**。由调用方决定这个 Agent 挂哪些 Plugin 和工具,而不是依赖平台自动组装。显式装配是权限可审计的前提。 第二,**产品边界与运行时分离**。产品负责用户体验;Agent 在明确边界内执行;运行时负责那些被反复重建的基础设施:模型、工具、任务、记忆、权限、用量、计费与可观测性。更完整的论证写在[白皮书](/zh/whitepaper/)里。 ## 常见问题 **Agent Harness 就是 Agent 框架吗?** 不是。框架是用来描述 Agent 逻辑的库;Harness 是执行它、持有它状态、约束它能做什么的运行时。多数团队最终两者都需要。 **已经用了 MCP,还需要 Harness 吗?** 如果 Agent 只是偶尔调几个工具,MCP 可能够用。一旦你需要可续跑的 Session、权限控制、成本归因,或者多个产品共用一个后端,你就需要一个运行时,那就是 Harness。 **Harness 只适用于编码 Agent 吗?** 不是。编码 Agent 让这个词流行,是因为它一次性压满了所有运行时要求:长 Session、文件读写、Shell 执行、审批。研究、运营、客服类 Agent 面对的是同一组要求。 **可以自己实现一个吗?** 可以,很多团队也是这么起步的。真实的代价是:你最终会把 Session 持久化、沙箱、权限校验、用量核算和多入口访问重新实现一遍。如果不想从零开始,[Downcity](/zh/start/) 是开源的。 ## 下一步 想先看到 Harness 跑起来再读理论,可以直接[安装 CLI 并启动第一个 Agent](/zh/start/),几分钟内就能拿到一个真实的 Session、Workspace 和工具边界。 想先理解架构,建议读[生产级 Agent 白皮书](/zh/whitepaper/),它解释了 Harness 这一层为什么存在,以及边界应该画在哪里。如果需要更细的产品文档,可以从[文档首页](/zh/docs/)进入。 ### Agent Harness 的内部构造:一条请求如何走完十个子系统 Source: https://downcity.ai/zh/blog/the-anatomy-of-an-agent-harness/ [Agent Harness 是什么](/zh/blog/what-is-an-agent-harness/)回答的是定义。这些职责在代码里具体落在哪里,是另一个问题。这篇回答第二个。 研究 Harness 最有效的方式不是看功能列表,而是跟一条请求。从用户提交任务开始,到 Agent 停下为止,中间碰过的每个子系统都会显形,连同它们被碰到的顺序。 ## 先看整体形状 ```mermaid flowchart TB IN["产品入口
控制台、扩展、API"] --> LOOP["执行循环"] LOOP --> CTX["上下文组装"] CTX --> GW["模型网关"] GW --> MODEL[("模型提供方")] GW --> LOOP LOOP --> REG["工具注册表"] REG --> GATE["权限闸门"] GATE --> BOX["沙箱"] BOX --> WS["Workspace
文件、Shell、git"] WS --> LOOP CTX <--> MEM["记忆"] LOOP <--> SESS[("Session 存储")] LOOP --> EVT["事件流"] SESS --> EVT LOOP --> LEDGER[("用量账本")] ``` 循环在中间,是因为其他部分要么在给它喂输入,要么在记录它做了什么。这张图可以按三个环来读:执行循环是控制流;网关和工具注册表是它向外伸手的通道;Session 存储、记忆、账本和事件流是负责记住的东西。 ## 一条请求的完整链路 用顺序描述同一个形状,通常比看框图更容易推理。 1. **任务到达。** 来自某个产品入口:控制台、浏览器扩展、API 调用或定时任务。 2. **Harness 打开或恢复一个 Session。** 如果是接续之前的对话,加载已有状态,而不是从零开始。 3. **组装上下文。** Harness 决定这一次调用真正要放进提示词的内容:系统指令、近期对话、检索出的记忆、工具 schema、Workspace 状态。这是一个预算分配决策,不是字符串拼接。 4. **通过网关调用模型。** 网关负责模型选择、流式输出和重试。 5. **模型返回。** 要么是最终答案,要么包含一个或多个工具调用。 6. **工具调用经过权限闸门。** 允许、拒绝,或挂起等待人工审批。 7. **获批的调用在沙箱内执行**,作用于 Workspace。 8. **结果写入 Session**,并折回上下文。循环回到第 3 步。 9. **全程发出事件并记录用量**,让这次运行可观测、可重放、可计费。 第 3 到第 8 步会反复执行,直到任务完成或触到上限。下面逐个说清每一步实际需要什么。 ## 十个子系统 ### 1. 执行循环 循环是唯一严格必需的部分。它调用模型、派发工具调用、追加结果,并决定何时停止。 真正需要工程判断的是停止条件。一个循环需要轮次上限、墙钟时间预算、token 预算,以及检测「在原地打转」的能力。缺了这些,一个迷糊的 Agent 会一直跑到有人发现账单为止。 ### 2. 模型网关 一层很薄的提供方抽象,前提是你有意去建它。网关负责模型选择、凭证处理、流式输出与重试。 它防止的失败模式是:提供方的差异渗进循环。一旦 `if provider == "x"` 出现在控制流里,换模型就从改配置变成了重构。网关同时也是归一化 token 统计的天然位置,因为各家提供方的用量上报口径并不一致。 ### 3. Session 存储 Session 是一段对话及其工作状态的持久记录。在 demo 里它是内存中的一个数组,在生产里它必须是一个有身份、被持久化的对象。 真正的 Session 存储和一份聊天记录的区别,在于它支持什么:进程重启后的**恢复**、在不破坏原分支的前提下做**分叉**、以及重建当时过程的**重放**。这三件事都要求以 Session 而不是进程作为事实来源。 这是最容易被低估的子系统。团队通常在一次部署中断了长任务之后,才发现自己需要它。 ### 4. 上下文组装 上下文组装决定模型每一轮看到什么。它是影响输出质量杠杆最大的子系统,也最容易做错。 这里有三件事:**选择**,即当下哪些指令、记忆和工具 schema 是相关的;**预算**,即如何塞进有限的上下文窗口;**压缩**,即历史超出窗口时该摘要或丢弃什么。 把完整对话记录无脑塞进每次调用,一开始能用,直到不能用。而它不能用的表现不是一个报错,而是输出质量缓慢下滑,且很难归因。 ### 5. Workspace Workspace 是 Agent 真正能碰的东西:一个目录树、一个 Shell,通常还有版本控制。它决定了一个 Agent 是在「聊工作」还是在「做工作」。 它的设计问题是边界画在哪里。限定在单个仓库内的 Workspace 更安全也更简单;能触达宿主机文件系统的 Workspace 能力更强,但也更难推理其行为。 ### 6. 工具注册表 注册表是「Agent 能调用什么」的清单。这里真正重要的设计选择是**显式装配**与自动发现的取舍。 自动装配看起来很省事,但它会侵蚀下游的一切。如果工具是在没有明确决策的情况下出现的,权限就无法审计,因为代码里根本不存在「某人决定过这个 Agent 可以做什么」的那个点。显式装配提供了那个决策点,也让权限闸门有一个稳定的校验对象。 每个工具还需要一份模型能读懂、Harness 能校验的 schema。一个返回非法结果的工具,比一个缺掉的工具更糟,因为模型会一直试图用它。 ### 7. 权限闸门与沙箱 这是两套不同机制,经常被混为一谈。 **权限闸门**是策略:这个工具允许,那个需要审批,这个在这个上下文里禁止。它在执行之前运行,产出一个决策和一条审计记录。 **沙箱**是强制手段:让策略变成现实的进程边界。如果闸门规定 Agent 不能写出 Workspace,那么当 Agent 仍然尝试时,是沙箱让这条规定成立。 只有闸门没有沙箱,是一份建议书。只有沙箱没有闸门,是一道没人管谁翻的围栏。两者都要,而且人工审批链路应该走在闸门里面,而不是旁边。 ### 8. 记忆 记忆是跨 Session 存活的东西,与「能跨进程存活但只属于一段对话」的 Session 状态不同。 难点不在存储,而在于:什么值得持久化、什么时候检索出来、以及如何避免检索到「上个月是对的、现在已经误导人」的内容。写进去却从未被正确检索出来的记忆,是纯粹的浪费。 ### 9. 用量账本 每次模型调用都有成本,账本负责把成本归因到某个 Session、用户、产品或租户。 这个子系统常常是后期才补,然后突然变得紧急,通常是因为有人问上个月的基础设施账单为什么翻了三倍。归因能力很难事后补救,因为信息必须在调用发生时就采集,也就是在网关那里,token 数量真实存在的地方。 ### 10. 事件流 事件流是「发生了什么」的记录:工具调用、审批、错误、产物、状态迁移。它让一次运行变得**可解释**,而不只是可重复。 它有两个消费者:排查意外结果的人,以及拿成本与活动对账的用量账本。如果事件是从散落各处的调用点随手发出的,这两边看到的都是残缺画面。 ## 缺了哪个器官,会出现什么症状 这是最有实用价值的部分。生产环境的症状,往往能相当准确地对应到缺失的子系统。 | 你观察到的症状 | 大概率缺失的子系统 | | --- | --- | | 部署之后 Agent 无法接着跑 | Session 存储只在内存里,没有持久化 | | 长对话后输出质量下滑 | 上下文组装没有压缩策略 | | 没人能解释一次糟糕的结果 | 事件流不完整或没有结构 | | 无法按客户核算成本 | 用量账本缺失,或事后才补 | | 工具访问无法审计 | 工具靠自动发现,没有显式装配 | | Agent 做出了超出预期的动作 | 有权限闸门,但沙箱没有真正强制 | | 重试调用产生了重复副作用 | 循环在工具执行处没有幂等处理 | | 一次运行永远不结束 | 循环缺少轮次、时间或 token 上限 | | 同样的上下文被反复检索又反复被忽略 | 有记忆写入,没有检索策略 | | 换模型需要改代码 | 提供方逻辑从网关里泄漏了出来 | 设计的时候把这张表倒过来读:每一个症状,都是一条你现在选择满足、还是选择以后才发现的要求。 ## 自研 Harness,还是采用现成的 诚实的说法是:Harness 起步不难,难在收尾。第一个版本,一个循环加上模型调用和几个工具,一个周末就能有;上面这些子系统,会吃掉之后的几个月。 自研还是采用的判断,通常归结为你认为哪些部分属于你的产品核心。Session 持久化、沙箱、权限策略、成本归因都是基础设施。它们很少是用户付钱给你的原因,而且它们都有可能在真实用户到来之后才以昂贵的方式暴露问题。 ## Downcity 的构造如何对应这份清单 Downcity 就是按这套拆解设计的,所以映射关系基本一一对应。 | 子系统 | Downcity 组件 | | --- | --- | | 执行循环、Session、Workspace | [Agent Harness](/zh/docs/agent/overview/) | | Session 生命周期与恢复 | [Agent 生命周期](/zh/docs/agent/lifecycle/) | | 模型选择与提供方访问 | [Federation](/zh/docs/federation/overview/) 与 [City 宿主](/zh/city-sdk-docs/) | | 工具注册、成员与 Plugin | [Agent Plugins](/zh/powers-docs/) | | 记忆作为可挂载能力 | [memory plugin](/zh/docs/power/overview/) | | 权限闸门与审批链路 | [权限模型](/zh/docs/security/permissions/) | | 沙箱与数据边界 | [安全总览](/zh/docs/security/overview/) | | 用量、Credits 与支付 | [账号与支付](/zh/payments/) | | 本地 CLI 操作与排查 | [CLI](/zh/docs/cli/overview/) | | 把运行时嵌进你的产品 | [Agent SDK](/zh/agent-sdk-docs/) | 这张表里有三个选择值得单独指出,因为它们是 Downcity 明确站边的地方。 **工具是显式装配的**,所以权限闸门永远有一个明确的决策可供校验。**权限闸门与沙箱是两套机制**,所以策略与强制可以分别推理。**用量核算放在模型边界上**,因为那是 token 数量真实存在、能支撑归因的唯一位置。 ## 常见问题 **执行循环就是 Agent 框架吗?** 不是。框架帮你描述 Agent 行为,循环是执行它的运行时。你可以用框架来写这个循环,也可以不用。 **一个 Harness 里一个 Agent 该支持多少 Session?** 取决于产品需要多少,这正是 Session 必须是可寻址对象、而不能是进程全局变量的原因。多租户产品通常需要一个带租户隔离的 Session 存储。 **上下文组装就是 RAG 吗?** RAG 只是上下文组装的一个输入。组装还包括系统指令、工具 schema、Workspace 状态,以及在预算用尽时决定丢弃什么。 **为什么要把权限闸门和沙箱分开?** 因为它们回答不同问题。闸门回答「这件事该不该被允许」,产出策略决策与审计线索;沙箱回答「这件事物理上能不能发生」,产出强制力。把两者合并,会让两边都更难测试。 **用量应该记在哪里?** 在模型网关上,随调用一起记录。那是提供方上报的 token 数量真实存在的唯一位置。事后从日志里反推,最多只能是近似。 **这些子系统能逐步加上去吗?** 可以,多数团队也是这么做的。比较实际的顺序是:先做 Session 持久化,再做权限与沙箱,然后是用量归因。这三件事事后补的代价最高。 ## 下一步 如果这套构造讲得通,想先看它跑起来,可以[启动第一个 Agent](/zh/start/),观察一个真实的 Session、Workspace 和工具边界。[CLI 文档](/zh/docs/cli/overview/)说明了你用来检查状态的命令。 如果你正在拿它对照自己的架构,[白皮书](/zh/whitepaper/)讲了这些边界为什么该这样画,[功能页](/zh/features/)则列出了每个子系统在实际产品里覆盖的范围。 ### Agent Harness vs MCP:不是竞争关系,是不同的层 Source: https://downcity.ai/zh/blog/agent-harness-vs-mcp/ 这个比较经常出现,而且通常被说成一道选择题:该用 MCP,还是需要一个 Harness? 这个提法本身有问题。MCP 和 Harness 在不同的层、做不同的事。问「该用哪个」,就像问「该用 HTTP 还是该用服务器」——它们存在的原因不同,而且你大概率两个都会有。 ```mermaid flowchart LR subgraph H["Agent Harness"] LOOP["执行循环"] --> SESS[("Session")] LOOP --> GATE["权限闸门"] end GATE --> M1["MCP 客户端"] GATE --> T2["原生工具"] M1 --> S1["MCP Server A"] M1 --> S2["MCP Server B"] ``` Harness 持有 Agent 本身:它的循环、Session、权限。MCP 处在边缘,描述 Agent 如何触达一个工具。两者的交界就在这条边界上。 ## MCP 到底定义了什么 MCP(Model Context Protocol)是一个连接标准。它规定 Agent 如何发现并调用外部能力:工具、资源与提示。 它标准化的事情确实很有价值。在 MCP 之前,每个集成的样子都不一样,每个 Agent 框架也有自己的一套约定。MCP 用一份契约替掉了成堆的定制连接器,这也是它普及得快的原因。 但它**有意不去覆盖**的部分同样重要。MCP 不定义: - Agent 能跑多久,以及跨重启之后怎么办 - Agent 被允许做什么,例外由谁审批 - 是哪个 Session 发起了调用,用的谁的凭证 - 这次调用花了多少成本,由谁买单 - Agent 的 Workspace 放在哪里 这些问题都在协议之外,因为它们属于运行时。 ## Harness 负责什么 Harness 是执行 Agent 的运行时。它跑循环、持有 Session 状态、给 Agent 一块 Workspace、装配工具、执行权限、记录用量。 这里值得停一下的是:工具就是工具。一项能力无论是通过 MCP 接入,还是直接编译进进程,Harness 都仍然要决定要不要挂载它、要不要放行它、要记录什么。MCP 改变的是工具的**连接方式**,不是运行时**必须做的治理**。 ## 为什么会被混淆 三个原因,而且乍看都说得通。 **两者都在谈工具。** MCP 标准化工具访问,Harness 也关心工具。名词相同,动作不同:MCP 描述连接,Harness 治理调用。 **它们差不多同时出现。** 同一时期开始做 Agent 的团队会一起遇到这两个概念,常常就在同一个 README 里,于是以为是二选一。 **有些 Agent 框架同时带了两者的一部分。** 一个既提供工具调用、又内置 MCP 客户端的库,看上去像个运行时。等到某项任务需要在重启后继续时,才会看清它不是。 ## 并排对比 | 问题 | MCP | Agent Harness | | --- | --- | --- | | 处在哪一层 | Agent 边缘的协议 | 包裹 Agent 的运行时 | | 主要职责 | 描述如何触达工具 | 执行并治理 Agent | | 管理 Session | 不 | 是 | | 管理权限 | 不 | 是 | | 管理成本核算 | 不 | 是 | | 是否标准化 | 是,一份共享契约 | 否,各家实现不同 | | 是否要二选一 | 不 | 不 | 最后一行就是本文的重点。 ## 实际工程中如何组合 以一次经 MCP 传入的工具调用为例,按顺序展开: 1. **Harness 组装这一轮的上下文**,其中包含这个 Agent 可以使用的工具 schema。 2. **模型产出工具调用**,指名其中一个工具。 3. **Harness 的权限闸门评估这次调用**:放行、拒绝,或挂起等待审批。MCP 在这里没有意见,因为这是策略问题。 4. **Harness 的 MCP 客户端发起调用**,打到已配置的 server。 5. **Server 执行并返回结果。** 6. **Harness 记录事件与成本**,并把结果追加进 Session。 7. **循环带着更新后的上下文继续。** MCP 贡献的是第 4 步和第 5 步的一部分,其余全归 Harness。再读一遍这份清单,注意其中有很大比例是**治理**而不是**连通性**——这正是「二选一」这个提法会漏掉的东西。 ## 边界在什么时候会显形 两种场景会让分工变得非常清楚。 **审计。** 有人问:上个季度这个 Agent 能够触达哪些工具,谁批准的。MCP 答不了,因为它没有「Agent 配置随时间变化」的概念。Harness 答得了,因为挂载是它做过的决策,也是它记录下来的东西。 **多租户。** 两个客户用不同凭证、不同预算连到同一个 MCP server。协议里没有任何东西能区分他们。Session、身份与用量归因都是运行时的事。 ## 你需要 MCP 吗 不一定。如果 Agent 只需要少数几项稳定能力,原生工具更简单:不用额外跑 server、没有网络跳转、也不必在边缘做 schema 协商。 MCP 的价值在于**当一项能力需要被多个 Agent 复用**,或者**第三方已经发布了工具、你不想重新实现**。协议的价值随消费方数量增长,所以你的规模越大,它越有意义。 ## 你需要 Harness 吗 如果 Agent 只跑一次就退出、在单个进程里、只有你自己用,那么一个带模型调用的循环可能真的够用。 一旦上述任一条件被打破,你就需要运行时。这意味着:任务活得比进程久时需要持久化 Session;别人的工作牵涉进来时需要权限模型;别人付账单时需要成本归因。这些都不是协议能提供的东西。 ## Downcity 怎么处理 MCP Downcity 的立场直接来自这条边界:**把 MCP 当作工具的来源之一,而不是运行时。** 具体来说,MCP server 和其他能力一样被挂载。Harness 持有循环、Session、权限闸门和用量账本;MCP server 在边缘提供一个工具。这意味着经 MCP 接入的工具与原生工具**通过同一个权限闸门、落入同一条审计线索**——这正是你想要的,也正是单靠一份协议拿不到的。 - [Agent Plugins](/zh/powers-docs/) 讲工具的挂载方式。 - [权限模型](/zh/docs/security/permissions/) 讲每次工具调用都要经过的闸门。 - [Agent 总览](/zh/docs/agent/overview/) 讲循环与 Session 模型。 ## 常见问题 **MCP 是 Agent 框架的替代品吗?** 不是。框架帮你描述 Agent 行为,MCP 描述工具连通性。它们不是同一类东西,多数项目两者都用。 **可以只用 MCP、不用 Harness 吗?** 对短时、单进程、单用户的 Agent 可以。代价是你放弃了 Session 持久化、权限和成本归因——现在可能无所谓,以后可能很急。 **Harness 一定要用 MCP 吗?** 不一定。原生工具少一跳,排查也更简单。当能力需要被共享、或来自第三方时,MCP 才值这份开销。 **应该先上哪一个?** 先上先出问题的那一层。如果是工具本身的问题,上 MCP;如果是 Session、权限或成本的问题,你需要的是 Harness,MCP 可以晚点再说。 **MCP server 安全吗?** Server 就是一段你选择运行的代码,值得用对待任何依赖的标准去审。Harness 的沙箱与权限闸门会限制一次调用能够触达的范围,这正是把治理留在运行时的意义。 **MCP 会取代 API 吗?** 不完全是。它标准化的是 Agent 触达能力的方式,而这些能力背后可能仍然是 API。它是给 Agent 访问用的契约,不是它背后那些系统的替代品。 ## 下一步 想在能跑的代码里看到这条边界,可以[启动一个 Agent](/zh/start/),挂载一个工具,再看它产生的权限记录。[白皮书](/zh/whitepaper/)论证了为什么应该把治理放在运行时而不是协议里。 如果还在建立概念,可以先读 [Agent Harness 是什么](/zh/blog/what-is-an-agent-harness/),再看[内部构造](/zh/blog/the-anatomy-of-an-agent-harness/),它逐个走完了运行时持有的子系统。 ### 智能体框架怎么选:先分清三类东西,再看四个维度 Source: https://downcity.ai/zh/blog/how-to-choose-an-agent-framework/ 搜「智能体框架」的时候,你会发现候选名单里混着三类完全不同的东西:LangChain 这类编排库、Dify 这类可视化平台,还有 Downcity 这类 Agent 运行时。它们都能出现在演示里跑通一个 Agent,但解决的问题不在一个层面上。 所以选型的第一步不是对比功能表,而是先认清你要的是哪一类。 ## 三类东西的区别 | | 编排库 | 低代码平台 | Agent 运行时(Harness) | | --- | --- | --- | --- | | 你拿到的是什么 | 一套代码库 | 一个可以点的界面 | 一套运行环境 | | 谁负责进程与部署 | 你的应用 | 平台 | 运行时 | | 状态存在哪 | 由你实现 | 平台内部 | 运行时的 Session 存储 | | 权限边界 | 由你实现 | 平台策略 | 闸门 + 沙箱 | | 成本归因 | 由你实现 | 平台统计 | 运行时账本 | | 适合谁 | 要深度定制、且愿意自己补基础设施的团队 | 想快速验证、不打算写代码的业务团队 | 要做成可交付、可运营产品的团队 | 这张表里最关键的一列是「由你实现」。编排库和平台并不是不好,而是它们**不承诺**承担状态、权限和成本这三件事。你可以自己写,只是要知道这是你自己在写。 ## 四个判断维度 不要从「哪个框架更火」开始问,从这四个问题开始。 ### 一、这个 Agent 跑多久 如果它在一次请求里跑完就结束,进程内存在就够了。 如果它需要跑几周、跨重启、跨部署,那你必须有一个能持久化、能恢复、能分叉的 Session 存储。这是「编排库能不能用」的第一道分水岭,也是最常被低估的一条。 ### 二、谁来碰它碰的东西 只给自己用的时候,工具权限放开无所谓。 一旦有真实用户,你就必须能回答:这个 Agent 可以读写哪些路径、可以调用哪些外部接口、哪些动作需要人工审批。注意这里有两件事——**策略**(该不该允许)和**强制**(物理上能不能发生)——只做前者的系统,出问题时挡不住。 ### 三、多个产品是否共用一套后端 多数团队要交付的不是一个 Agent,而是控制台、扩展、API 和内部工具同时要跑同一套模型与账号。 如果每个入口各接一遍,你会得到四份模型配置、四份用量记录和四份账单。能不能复用同一套后端,决定了后续维护成本是线性还是指数。 ### 四、成本能不能归因 token 开销是真实成本。如果用量无法归因到某个用户、产品或会话,你就没法定价、限流,也没法回答「上个月账单为什么翻了三倍」。 归因必须在调用发生时采集(也就是在模型边界上),事后从日志反推只能得到近似值。 ## 选型清单 把下面几项直接拿去问候选方案,答案比任何功能列表都有用: - [ ] 进程重启之后,正在跑的任务能不能接着跑? - [ ] 会话能否恢复、分叉、重放? - [ ] 工具是显式装配的,还是自动发现的? - [ ] 权限是策略,还是同时有沙箱强制? - [ ] 是否有需要人工审批的动作,审批链路在哪一层? - [ ] 用量能否按用户 / 产品 / 会话拆开? - [ ] 多个产品能否复用同一套模型与账号体系? - [ ] 换模型要改配置,还是改代码? - [ ] 出问题时,能否还原「它做了什么、为什么」? 如果候选方案在「重启后能不能接着跑」这一项就答不上来,那它多半是编排库,你需要再补一层运行时。 ## 三个常见误区 **误区一:把编排库当成运行时。** 编排库擅长表达逻辑,不负责进程、持久化和权限。demo 跑通不等于能运营。 **误区二:以为上了可视化平台就不用管架构。** 平台替你挡掉了一部分问题,代价是你的状态与权限被锁在平台里;当你要把 Agent 做成自己的产品时,这层依赖会变成迁移成本。 **误区三:先挑模型,最后才想运行时。** 模型是可以换的(前提是提供方逻辑没有渗进你的控制流)。运行时决定了这个 Agent 能不能长期活着,换起来要贵得多,应该先定。 ## Downcity 处在哪里 Downcity 是第三类:一套开源 Agent Harness 加上让它可交付的配套 Kit。它不替代你已有的编排方式,而是把上面那四件事收进运行时: | 你关心的问题 | Downcity 对应的部分 | | --- | --- | | 循环、Session、Workspace | [Agent Harness](/zh/docs/agent/overview/) | | Session 生命周期与恢复 | [Agent 生命周期](/zh/docs/agent/lifecycle/) | | 模型目录与服务路由 | [Federation](/zh/docs/federation/overview/) 与 [City](/zh/city-sdk-docs/) | | 工具与插件显式装配 | [Agent Plugins](/zh/powers-docs/) | | 权限闸门 | [权限模型](/zh/docs/security/permissions/) | | 沙箱与数据边界 | [安全总览](/zh/docs/security/overview/) | | 用量、Credits 与支付 | [账号与支付](/zh/payments/) | | 嵌进你自己的应用 | [Agent SDK](/zh/agent-sdk-docs/) | | 面向 Agent 的界面 | [UI SDK](/zh/product/ui-sdk/) | 两个设计选择值得单独说明:**能力显式装配**(由调用方决定挂哪些工具,权限才可审计),以及**产品边界与运行时分离**(产品负责体验,运行时负责模型、权限、用量这些被反复重建的基础设施)。 ## 常见问题 **智能体框架有哪些类型?** 大致三类:编排库(偏代码、要自己补基础设施)、低代码平台(偏可视化、状态与权限在平台内)、Agent 运行时(承担进程、状态、权限与成本)。选之前先确定你要哪一类。 **智能体框架和 Agent Harness 是一回事吗?** 不是。框架帮你**描述** Agent 的行为,Harness 是**运行**它并治理它的那层。多数团队最终两者都需要。 **可以不用框架,直接自己写吗?** 可以,而且很多团队就是这么起步的。诚实地说:你会把会话持久化、沙箱、权限校验、用量核算和多入口访问重新实现一遍。如果没有特殊诉求,从现成的运行时起步更快。 **选型时最该先确认哪一条?** 「重启之后能不能接着跑」。这一条能最快区分编排库和运行时,也是后期最难补的。 **中文生态里常见的那些工具怎么归类?** 判断方法同上,看它承不承担进程、状态、权限和成本。不承担的那部分,最终都要由你补上。 ## 下一步 想先动手看效果,可以[安装并启动第一个 Agent](/zh/start/),几分钟内就能拿到一个真实的 Session、Workspace 和权限边界。 想先看清架构,建议读 [Agent Harness 是什么](/zh/blog/what-is-an-agent-harness/) 和 [内部构造](/zh/blog/the-anatomy-of-an-agent-harness/),后者逐个拆解了运行时持有的十个子系统;[白皮书](/zh/whitepaper/)则解释了这些边界为什么该这么画。