# Connectors and MCP Source: https://docs.cycls.com/agents/connectors A user connects an account once, and the tools that need it get a live credential. OAuth, pasted keys, private endpoints and remote MCP servers. A connector is a grant a person makes, for themselves or for a workspace, plus the policy over the tools that use it. You declare it once, the directory renders it, and the credential is attached server-side when a tool runs. Your code never holds a token and neither does the browser. ```python catalog.py theme={null} import cycls notion = cycls.OAuth2( "notion", authorize="https://api.notion.com/v1/oauth/authorize", token="https://api.notion.com/v1/oauth/token", client_id=cycls.env("NOTION_CLIENT_ID"), secret=cycls.env("NOTION_CLIENT_SECRET"), scopes=["read_content", "update_content"], api="https://api.notion.com", api_headers={"Notion-Version": "2022-06-28"}, scope="either", ) web = cycls.Web().auth(cycls.Clerk()).connectors(notion) llm = cycls.LLM().model("anthropic/claude-sonnet-4-6").connectors(notion) ``` Declare the same objects on both builders. `Web` serves the directory and the connect routes, `LLM` decides whose tools the loop may offer. ## Three kinds of grant Authorization code with PKCE against an app you registered, or against an MCP server that publishes its own authorization server and registers clients dynamically. ```python theme={null} github = cycls.OAuth2( "github", authorize="https://github.com/login/oauth/authorize", token="https://github.com/login/oauth/access_token", client_id=cycls.env("GITHUB_CLIENT_ID"), secret=cycls.env("GITHUB_CLIENT_SECRET"), scopes=["repo", "read:org"], extra={"prompt": "consent"}, scope="user", ) ``` Tokens refresh when stale. The state that rides through the provider is signed and names the user and workspace, so the callback carries no JWT. For services where the user pastes a key. ```python theme={null} posthog = cycls.Key( "posthog", hint="phx_...", api="https://app.posthog.com", auth="bearer", scope="workspace", ) ``` `hint` is the field placeholder. Override `validate(value)` on a subclass to reject a malformed key before it is stored. For services where the secret is the address, such as a private MCP link a merchant copies from their dashboard. ```python theme={null} zid = cycls.Endpoint("zid", host=".zid.sa", scope="workspace") ``` `host` bounds what counts as a valid link, so a typo or an internal address is refused rather than dialled. ## Where a grant lives ```python theme={null} cycls.OAuth2("gmail", ..., scope="user") # personal, follows the person cycls.Key("posthog", ..., scope="workspace") # shared with the team cycls.OAuth2("drive", ..., scope="either") # the person chooses ``` A mailbox is a person, so `user`. A team analytics key is shared, so `workspace`. `either` lets an admin connect one account for everyone while individuals can still link their own. ## Calling a REST API Give a connector an `api` base and the loop offers one `{name}_request` tool. The model builds a path, the platform attaches the credential, and the host is bounded to that base. ```python theme={null} linear = cycls.Key("linear", api="https://api.linear.app", auth="bearer") ``` The same relay backs `cycls.connector(name)` inside a generated app, so a dashboard an agent builds can read live data without ever holding a key. | Argument | Meaning | | ------------- | ---------------------------------------------- | | `api` | https base URL the relay is bounded to | | `api_headers` | fixed headers the API requires on every call | | `auth` | `bearer`, `basic`, `header` or `query` | | `auth_name` | header or query parameter name when not bearer | ## MCP servers `cycls.MCP` connects to a remote MCP server. The harness speaks Streamable HTTP itself, so every provider gets MCP, not just Anthropic. Tools are discovered once and cached, and a session opens only when a tool is actually called. ```python theme={null} server = ( cycls.MCP("https://mcp.example.com/mcp") .name("example") .token(cycls.env("EXAMPLE_TOKEN")) .allow("search", "create_issue") ) llm = cycls.LLM().mcp(server) ``` | Method | Meaning | | ------------------- | ------------------------------------------------------------------------- | | `.name(alias)` | the name shown in the UI and used in the tool index | | `.token(bearer)` | a fixed bearer token | | `.connector(oauth)` | act with a user's grant instead of a fixed token | | `.allow(*names)` | offer only these tools | | `.guidance(text)` | extra prompt guidance for this server | | `.writes(fn)` | classify which calls change something, for approvals | | `.server_side()` | hand the server to the Anthropic connector instead, Anthropic models only | ## Loading tools on demand A directory of connectors can hold hundreds of tools, and their schemas would not fit in a system prompt. Each connector contributes one index line of about fifteen tokens instead, and the model calls `find_tools` to load the schemas it needs. Loaded tools stay for the rest of the conversation. ## Approvals Every tool call is classified before it runs. | Risk | Behavior | | ----------- | ---------------------------------- | | read | always runs | | write | follows the composer's Auto switch | | destructive | asks in both modes | When a call needs approval, the model gets a stop and the user gets a confirm card naming the tool and the arguments. Approving binds to those exact arguments, so a changed call asks again. Each person can also set allow, ask or never per tool, and an organization admin can switch a connector off for everyone. Deletes through bash are not destructive by default, because the sandbox shims `rm` into a thirty day trash. Commands with nothing behind them, such as `shred`, `mkfs` or `git push --force`, always ask. ## Directory copy What the directory shows can come from your code or from a CMS, field by field, with code winning. ```python theme={null} web = cycls.Web().cms( brand="https://cms.example.com/agents/my-agent", connectors="https://cms.example.com/connectors", token=os.environ["CMS_TOKEN"], ) ``` This keeps behavior in Python, where it belongs, and copy in a CMS where a writer can change it without a redeploy. Connector objects are built on your machine and pickled into the deployment. Keep the declaration file free of functions and module-level values a callable would close over: a `writes` classifier or a tool handler must live in the file you deploy, not in an imported module. ## Next What isolates the bash tool, and what it does not cover. # Context Source: https://docs.cycls.com/agents/context Every turn hands your agent a context object with the conversation, the user, the workspace and the switches that user set. The single argument to an agent body is `context`. It is the whole per-request input, and `llm.run(context=context)` reads everything it needs from it. ```python theme={null} @cycls.agent(web=web, volumes={"/workspace": chats}) async def my_agent(context): print(context.last_message) print(context.user.id, context.user.plan) async for ev in llm.run(context=context): yield ev ``` ## Fields | Field | Type | Meaning | | ------------------------ | ---------------- | -------------------------------------------------------------------- | | `context.messages` | `Messages` | the conversation as `{"role", "content"}` dicts | | `context.messages.raw` | `list` | the same turns with every part, including components and attachments | | `context.last_message` | `str` | the text of the most recent message | | `context.user` | `User` or `None` | the authenticated user when auth is configured | | `context.chat_id` | `str` | the current chat | | `context.workspace` | `Workspace` | this user's storage scope | | `context.workspace_id` | `str` or `None` | the active workspace in multi-workspace mode | | `context.prod` | `bool` | `True` under `deploy`, `False` under `run` | | `context.disabled_tools` | `list` | tools this user switched off in Settings | | `context.connectors` | `list` | connectors the user mentioned with `@` | | `context.approvals` | `list` | approvals granted from a confirm card, this turn only | | `context.auto` | `bool` | the composer's automatic approval switch | ## Messages `context.messages` reads as plain text, which is what most code wants. ```python theme={null} [{"role": "user", "content": "Summarize this"}, {"role": "assistant", "content": "Here is the summary"}] ``` `context.messages.raw` keeps everything: component parts, tool steps and attachment references. Reach for it when you need to inspect what actually happened rather than what was said. ## The user ```python theme={null} user = context.user user.id # stable user id user.org_id # organization id, when the user is acting in one user.org_slug user.org_role user.org_permissions user.plan # subscription tier from the JWT claim user.features # feature flags ``` `context.user` is `None` when no auth provider is configured. See [Authentication](/web/auth) for how the claims are populated. ## The workspace `context.workspace` is this user's scope on the `/workspace` volume, and it is what `cycls.DB` writes to. ```python theme={null} from datetime import datetime, timezone db = cycls.DB(context.workspace) month = datetime.now(timezone.utc).strftime("%Y-%m") entry = await db.get(f"usage/{month}", {"count": 0}) entry["count"] += 1 await db.put(f"usage/{month}", entry) ``` Files the user sees in the files panel live under `context.workspace.root`. ## Gating on environment `context.prod` separates a local run from a deployment, which is the clean way to keep billing and quota checks out of your development loop. ```python theme={null} exempt = not context.prod if context.user.plan == "o:free_org" and not exempt: yield {"type": "callout", "callout": "This workspace needs a paid plan.", "style": "error"} yield {"type": "ui", "action": "open_plan_modal"} return ``` ## Respecting user switches `context.disabled_tools` holds the tools this person turned off. `llm.run()` already honors it. You only need to read it if you are running your own loop or want to explain the difference to the user. ```python theme={null} if "WebSearch" in context.disabled_tools: yield "_Web search is off in your settings, so this answer is from memory._\n\n" ``` A switch can never enable a tool that `allowed_tools` did not grant. ## Logging with attribution `cycls.log` writes one structured line that the log backend captures, with user and chat attribution attached. ```python theme={null} cycls.log("cap_hit", user=context.user, chat_id=context.chat_id, kind="user_free_monthly", count=entry["count"]) ``` Query it later with `cycls logs -q` or `cycls sql`. See [Observability](/ship/observability). ## Next Workspace instructions and skills the model loads on demand. # Custom loops Source: https://docs.cycls.com/agents/custom-loop Hook the default loop with a few lines, or replace it entirely and keep the session, provider, tools and compaction. Most agents never need this page. The default loop handles streaming, tools, retries, compaction and persistence. Reach for a custom loop when your product needs orchestration the loop does not model, such as routing between models, running a planning pass, or enforcing a state machine. ## Hooking without replacing Events are dicts, so you can observe and react while still passing everything through. ```python theme={null} async for ev in llm.run(context=context): if isinstance(ev, dict): if ev.get("type") == "step" and ev.get("tool_name") == "web_search": metrics.increment("search") if ev.get("type") == "callout" and ev.get("style") == "error": alert_ops(ev["callout"]) yield ev ``` You can also transform or drop events, or inject your own around them. ```python theme={null} yield {"type": "status", "status": "Working"} async for ev in llm.run(context=context): yield ev yield {"type": "ui", "action": "suggest", "text": "Turn this into a report"} ``` ## Replacing the loop ```python theme={null} from cycls._agent.harness import ( default_loop, make_provider, Session, build_tools, dispatch, compact, events, ) async def my_loop(*, context, system="", tools=None, allowed_tools=(), model=None, **kw): provider = make_provider(model) session = await Session.open(context) await session.add_user(context.messages.raw[-1].get("content", "")) tool_list = build_tools(allowed_tools, tools or [], vendor=model.split("/", 1)[0]) async for ev in provider.stream( messages=session.context(), system=system, tools=tool_list, max_tokens=kw.get("max_tokens") or 8192, ): yield ev await session.checkpoint() llm = cycls.LLM().model("anthropic/claude-sonnet-4-6").loop(my_loop) ``` `fn` is an async generator that yields events. Accept `**kw` so new keyword arguments do not break your loop as the SDK grows. ## The kit | Piece | What it does | | ---------------------------------------------------- | ---------------------------------------------------------------------- | | `default_loop` | the built-in loop, callable directly if you want to wrap it | | `make_provider(model, ...)` | one streaming interface over Anthropic and OpenAI-compatible endpoints | | `Session` | the message log and its persistence | | `build_tools(allowed, custom, vendor=, web_search=)` | the provider-neutral tool list | | `dispatch(block, workspace, timeout, handlers, ...)` | run one tool call, returns a step event and a coroutine | | `compact(provider, messages, keep, max_tokens)` | summarize all but the most recent `keep` tokens | | `events` | dict factories for `text`, `thinking`, `step`, `callout`, `tool_call` | | `ToolContext` | who a tool acts for: user, workspace, chat, approvals | ## Session `Session` is the part worth understanding, because it is what makes a run survivable. ```python theme={null} session = await Session.open(context) await session.add_user(content) # checkpoints the user turn before any model call session.messages # the working message list session.context() # the window the provider should see await session.checkpoint() # persist what has happened so far session.rollback() # drop the assistant tail after a failure await session.clear(keep, enough) # stub old tool results, False if that frees under enough await session.compact(provider) # summarize older turns ``` `add_user` writes to disk before the model is called, so a dropped connection never loses what the person said. `rollback` removes only the assistant tail, so a failed turn does not corrupt the transcript. `clear` and `compact` change only what `context()` returns. The turns on disk stay as they were. ## The provider interface ```python theme={null} class Provider(Protocol): model: str def stream(self, *, messages, system, tools, max_tokens, mcp_servers=None, thinking=None) -> AsyncIterator: ... async def complete(self, *, messages, system, max_tokens) -> str: ... ``` `stream` yields UI events, bare strings for text deltas, then exactly one `Turn` carrying the assistant content, stop reason and token counts. Adding a new provider means one file that conforms to this protocol. The message shape is Anthropic's, which is the richest superset across vendors. Each provider translates from it to its own wire format. ## What you give up Writing your own loop means owning the behavior the default one provides: retries with backoff, clearing and compaction before overflow, one recovery replay on a context error, checkpointing between tool batches, heartbeats during long tool runs, cost logging, and unwinding cleanly on cancellation. Start by wrapping `default_loop` and intercepting what you need, then replace it only when wrapping stops being enough. ## Next Branding, themes, the explore menu and the example gallery. # Files and the canvas Source: https://docs.cycls.com/agents/files Every user gets a workspace of files. The canvas renders spreadsheets, documents, slides, PDFs, images and HTML without leaving the chat. Each user of an agent gets a file tree on the volume mounted at `/workspace`. The `Editor` and `Bash` tools read and write it, the files panel lists it, and the canvas renders one file at a time beside the conversation. ## Where files live ``` /workspace// AGENT.md instructions, opened from the dock, not listed in Files report.md files the user keeps media/ images and videos added to report.md in the editor data/sales.csv apps/burnup/ apps the agent built skills// skills the user created .tmp/ scratch, hidden and cleaned up .db/ chat store, not readable from the sandbox ``` Paths in tools are workspace relative. `~/report.md` and `/workspace/report.md` both resolve to the same file, traversal outside the workspace is refused, and `/tmp` raises an explicit error because every bash command gets its own `/tmp` that no other tool can see. Scratch belongs in `.tmp/`. It is hidden from the files panel and cleaned up, so intermediate downloads and working data do not clutter what the user sees. ## Opening a file on the canvas The `Canvas` tool opens one finished file in the side panel. ```python theme={null} llm = cycls.LLM().allowed_tools(["Bash", "Editor", "Canvas"]) ``` From a custom loop, or from your own code, the same action is a `ui` event: ```python theme={null} yield {"type": "ui", "action": "open_canvas", "path": "report.xlsx"} ``` ## What the canvas renders | File type | Rendered as | Where the work happens | | ---------------------------------------------------------------- | ------------------------------------------------------- | ---------------------- | | `csv`, `tsv`, `xls`, `xlsx`, `xlsm`, `ods` | interactive grid with sheet tabs | in the browser | | `docx` | formatted document with pages, fonts, tables and images | in the browser | | `ppt`, `pptx`, `odp` | slide viewer with a thumbnail rail | office render service | | `doc`, `odt`, `rtf`, `epub` | read-only PDF | office render service | | `pdf` | PDF viewer | in the browser | | `md`, `html`, images, audio, video, `glb`, `gltf`, code and text | native viewer | in the browser | | anything else | download card | | Any failure, including an unreachable service or an unparseable file, falls back to the download card rather than an error page. ## Editing on the canvas Text files open with an **Edit** button. Markdown opens in a rich editor with a toolbar, a `/` menu for inserting blocks, and drag handles for moving them. It covers headings, lists, checklists, tables, quotes, code, links and dividers, and saving writes plain markdown back to the file. Each block takes its direction from its own text, so Arabic and English paragraphs sit side by side. Images and videos pasted, dropped or picked into a document are saved in a `media/` folder beside it and linked by a relative path. The canvas shows those images and plays those videos in the markdown view too. Markdown that contains math, raw HTML or footnotes opens in a plain-text editor instead, so saving cannot drop them. Other text and code files also edit as plain text. Every other format on the canvas is view-only. ## Configuring office rendering Spreadsheets and `.docx` render from raw bytes in the browser and need no service. Presentations and the PDF fallback call a shared render service, configured with two environment variables: ```bash theme={null} OFFICE_RENDER_URL=https://office-render.cycls.ai OFFICE_RENDER_SECRET=your_shared_secret ``` With either unset, those two paths return a download card and everything else keeps working. LibreOffice is about 1GB and is used by a small fraction of turns, which is why it runs in one shared service rather than inside every agent image. Rendered output is cached under a hidden `.cache/office/` directory in the workspace, so reopening a deck does not repay the conversion. ## Uploads Users attach files in the composer. The loop ingests them before the model call: images and PDFs go to the model as media when `.vision(True)`, and every attachment is written into the workspace so tools can open it. ```python theme={null} web = cycls.Web().max_upload(256) # MB per file, default 512 ``` For text-only models, set `.vision(False)`. The attachment stays in the workspace and the model receives a note naming the file, which it can then read with a tool. ## The HTTP API Files are also a REST surface, which is what the files panel and mobile clients use. All routes require auth. | Method | Path | Purpose | | -------- | -------------------- | ------------------------------------------------------------ | | `GET` | `/files?path=subdir` | list a directory | | `GET` | `/files/{path}` | download, add `?download` for an attachment header | | `PUT` | `/files/{path}` | upload, the raw body is the file, multipart is also accepted | | `PATCH` | `/files/{path}` | rename, body `{"to": "new/path"}` | | `POST` | `/files/{path}` | create a directory | | `DELETE` | `/files/{path}` | delete, which moves the file to trash | Deleting moves a file to trash and keeps it restorable for thirty days through `GET /trash`, `POST /trash/{id}/restore` and `DELETE /trash/{id}`. ## Sharing ``` POST /share create a share link for a chat GET /share list share links DELETE /share/{token} revoke GET /shared/{user}/{token} the public page POST /shared/{user}/{token}/fork copy the conversation into the viewer's workspace ``` A shared chat carries the files it references, so a recipient can open the artifact without an account. Forking lands in the forker's active workspace. ## Next Let a user connect an account, then give tools the credential. # Instructions and skills Source: https://docs.cycls.com/agents/knowledge Give an agent standing instructions it reads every turn, and skill packs it loads only when the task calls for them. Two mechanisms sit between your system prompt and the model. Instructions are always in context. Skills are loaded on demand. ## Workspace instructions Every turn, the loop reads `AGENT.md` from the user's workspace root and appends it to the system prompt, fenced as user preferences that stay subordinate to your `.system()` prompt. ```markdown AGENT.md theme={null} Always answer in Arabic unless I write in English. Our fiscal year starts in April. When I ask for a report, use the template in templates/report.md. ``` In the web client, users open **Instructions** from the dock at the bottom of the right panel. The file opens in the canvas editor, and an empty one shows a few examples to start from. `AGENT.md` does not appear in the Files list. Users can also ask the agent to change it. Nothing is needed on your side, but the file name is configurable: ```python theme={null} llm = cycls.LLM().instructions("NOTES.md") # a different name llm = cycls.LLM().instructions(None) # disable the mechanism ``` The Instructions tile always opens `AGENT.md`, so keep the default name if your users edit their instructions in the web client. Content is capped at 24KB and truncated beyond that. Binary or unreadable files are ignored. ## Skills A skill is a folder of instructions the model pulls in when it decides the task calls for it. Only the name and description sit in the system prompt, which costs a line or two. The body enters context when the model calls the `skill` tool. ``` skills/ pdf-reports/ SKILL.md scripts/render.py templates/invoice.html ``` ```markdown SKILL.md theme={null} --- name: pdf-reports description: Generate branded PDF reports from CSV data. Use when the user asks for a PDF report, an invoice, or a printable summary. --- # PDF reports Run `python /skills/pdf-reports/scripts/render.py `. The template lives beside the script and expects columns: date, item, amount. ``` The frontmatter does the work. `name` is lowercase with hyphens and falls back to the folder name. `description` is what the model matches against, so write it as "what this does, and when to use it" in at most 1KB. The body can be up to 48KB. ### Shipping skills with the agent ```python theme={null} image = cycls.Image().copy("skills/") llm = cycls.LLM().skills("skills") ``` Shipped skills are read-only and mount at `/skills//` inside the bash sandbox, so scripts find their own templates by absolute path. They version with your deploys. ### Skills users create Any `skills//SKILL.md` in a user's workspace joins the catalog automatically, rescanned about every thirty seconds. A user skill wins a name collision with a shipped one, so someone can override your default without a redeploy. ```python theme={null} llm = cycls.LLM().skills(None) # turn the whole mechanism off ``` The description decides whether the model reaches for the skill, so write the trigger rather than the summary. "Use when the user asks for a PDF report, an invoice, or a printable summary" works better than "PDF utilities". ## Which to use | Situation | Use | | --------------------------------------------------- | ----------- | | A preference that applies to every turn | `AGENT.md` | | A procedure with support files, needed occasionally | a skill | | Product behavior you own and never want overridden | `.system()` | ## Next The workspace file tree, and how the canvas renders it. # Models Source: https://docs.cycls.com/agents/models One builder for every provider: Anthropic natively, and any OpenAI-compatible endpoint through a base URL. `cycls.LLM` holds everything about how an agent runs. It is immutable, so each method returns a new builder and one base config can be branched safely. ```python theme={null} llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .system("You are a helpful assistant.") .max_tokens(16_384) .context(200_000) .thinking("medium") .price(input=3, output=15, cache_read=0.30, cache_write=6) ) async for ev in llm.run(context=context): yield ev ``` ## Choosing a model Model strings are always `vendor/model`. Anything that is not `anthropic/` goes through the Chat Completions adapter, so `base_url` points it at the endpoint and `api_key` supplies the key. ```python theme={null} # Anthropic, native API. Reads ANTHROPIC_API_KEY cycls.LLM().model("anthropic/claude-sonnet-4-6") # OpenAI. Reads OPENAI_API_KEY cycls.LLM().model("openai/gpt-5.4") # Open weight models on their vendor endpoints cycls.LLM().model("deepseek/deepseek-flash").base_url("https://api.deepseek.com/v1").api_key(os.environ["DEEPSEEK_API_KEY"]) cycls.LLM().model("moonshotai/kimi-k3").base_url("https://api.moonshot.ai/v1").api_key(os.environ["MOONSHOT_API_KEY"]) cycls.LLM().model("zai/glm-5.3").base_url("https://open.bigmodel.cn/api/paas/v4").api_key(os.environ["ZAI_API_KEY"]) # Your own inference server cycls.LLM().model("local/kimi-k3").base_url("http://localhost:8000/v1").api_key("unused") ``` | Vendor prefix | Endpoint | Notes | | -------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------- | | `anthropic` | built in | native path: cache breakpoints, extended thinking, server-side search and MCP | | `openai` | built in | reads `OPENAI_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | open weights, thinking toggle plus effort | | `moonshotai`, `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | open weights, effort tiers low, high, max | | `zai`, `zhipu`, `glm` | `https://open.bigmodel.cn/api/paas/v4` | open weights, thinking is a binary toggle | | `qwen`, `dashscope` | vendor endpoint | open weights, thinking budget in tokens | | `groq`, `xai`, `mistral`, `google`, `perplexity`, `openrouter` | vendor endpoint | standard `reasoning_effort` | | `local`, `vllm`, `modal`, anything else | your URL | vLLM, SGLang, Ollama or a private gateway | The prefix selects the reasoning dialect, so use the vendor prefix that matches the API you are calling, even when you self-host that model. See [Self-hosted and open models](/guides/self-hosted-models) for serving your own. Model identifiers change as vendors ship new versions. Check the vendor's documentation for the current name before pinning one in production. Keys come from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) or from `.api_key()`. Use `.headers()` for endpoints that authenticate outside the bearer token, such as a Modal proxy or Cloudflare Access. ```python theme={null} llm = ( cycls.LLM() .model("modal/moonshotai/Kimi-K3") .base_url("https://your-workspace--kimi-k3.modal.direct/v1") .api_key("unused") .headers({"Modal-Key": os.environ["MODAL_TOKEN_ID"], "Modal-Secret": os.environ["MODAL_TOKEN_SECRET"]}) ) ``` ## Reasoning `.thinking()` is one unified control, translated into each vendor's dialect. ```python theme={null} cycls.LLM().thinking("adaptive") # default, the model decides cycls.LLM().thinking("low") # or "medium", "high" cycls.LLM().thinking(None) # off where the provider allows it ``` | Vendor prefix | Wire format | | ------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `openai`, `azure`, `google`, `gemini`, `xai`, `grok`, `mistral`, `groq`, `perplexity` | `reasoning_effort: low \| medium \| high` | | `zai`, `zhipu`, `glm` | `thinking: {type: enabled \| disabled}` | | `deepseek` | the GLM toggle plus `reasoning_effort` | | `qwen`, `dashscope`, `alibaba` | `enable_thinking` plus a token `thinking_budget` | | `kimi`, `moonshot`, `moonshotai` | `reasoning_effort: low \| high \| max` | | `openrouter` | `reasoning: {effort}` | | `anthropic` | extended thinking, auto-disabled on models without it | A vendor with no mapping, such as a host prefix like `modal` or `vllm`, gets no reasoning parameter and prints one warning. Use `.extra_body()` for those. ```python theme={null} llm = cycls.LLM().extra_body({"reasoning_effort": "high", "top_p": 0.9}) ``` `.extra_body()` merges after the built-in mapping, so your keys win. It is the escape hatch for any parameter Cycls does not model. ## Budgets and cost ```python theme={null} llm = ( cycls.LLM() .max_tokens(16_384) # output tokens per request, default 8k .context(200_000) # model context window, default 1M .price(input=3, output=15, cache_read=0.30, cache_write=6) ) ``` `.context()` is what decides when compaction starts, so set it to match the model you actually run. `.price()` takes USD per million tokens. With prices set, every turn logs its cost and `cycls cost` and `cycls sql` can slice spend by user, chat or model. Without it, costs report as zero. ## Long conversations The loop keeps a chat inside the context window without ending it. Before each model call, and once more when a run ends, it compares the last request's input tokens with a trigger. | | Value | | ------------- | ------------------------------------------------------------------------------------------ | | Trigger | 70% of `.context()`, or `.context()` minus `.max_tokens()` minus 30,000 when that is lower | | Kept verbatim | the most recent 30% of the window | Past the trigger, the loop frees room in two steps: 1. **Clear.** Old tool results, and tool arguments longer than 1,000 characters, are replaced with a short placeholder. Paths and commands stay, so the model still knows what it did. A loaded skill is never cleared. This makes no model call. 2. **Summarize.** When clearing would free less than 25% of the window, the model summarizes everything before the recent 30% instead. The request reuses the loop's system prompt and tools, so the provider serves most of it from cache. The user sees "Summarizing earlier messages to keep this chat going..." while it runs. A chat that resumes after 20 minutes idle is cleared before its first call when that frees 10% of the window. The provider's cache has mostly expired by then, so clearing costs little in cache hits. The system prompt, including `AGENT.md` and the skill catalog, is never cleared or summarized. The saved transcript is never rewritten either, so users always see the whole conversation. If a summary fails or takes longer than five minutes, the model stops seeing the older turns, gets a line saying they were dropped, and the chat continues. When the provider still reports that the context is full, the loop summarizes and replays the turn, once per run. One `read` returns at most 50,000 characters, so a single large file or attachment cannot fill the window by itself. ## Vision and text-only models Attachments are sent as base64 media by default. Text-only models reject that, so turn vision off and the file stays in the workspace with a note naming it, which the model can then open with a tool. ```python theme={null} llm = cycls.LLM().model("zai/glm-5.3").base_url("https://open.bigmodel.cn/api/paas/v4").vision(False) ``` ## Web search ```python theme={null} llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("brave") # default llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("native") # Anthropic server-side ``` `brave` is the portable search and fetch pair. It works on any model and needs `BRAVE_API_KEY`. Without that key it falls back to the provider's native search where one exists. `native` forces the provider's server-side search, which today means Anthropic only. ## Full builder reference | Method | Purpose | | ---------------------------------------------------- | ---------------------------------------------------------------- | | `.model(str)` | `vendor/model` string. Required | | `.system(str)` | system prompt | | `.tools(list)` | [custom tool schemas](/agents/tools#custom-tools) | | `.on(name, fn, label=, icon=, details=)` | handler for a custom tool | | `.allowed_tools(names)` | enable [built-in tools](/agents/tools) | | `.instructions(path)` | workspace instructions file, default `AGENT.md`, `None` disables | | `.skills(*dirs)` | [ship skills](/agents/knowledge#skills) with the agent | | `.context(n)` | context window in tokens, default 1M | | `.max_tokens(n)` | output tokens per request, default 8k | | `.price(input=, output=, cache_read=, cache_write=)` | USD per million tokens | | `.thinking(spec)` | `"adaptive"`, `"low"`, `"medium"`, `"high"` or `None` | | `.extra_body(params)` | vendor-specific request extras | | `.vision(bool)` | accept base64 media, default `True` | | `.web_search(mode)` | `"brave"` or `"native"` | | `.mcp(*servers)` | [remote MCP servers](/agents/connectors#mcp-servers) | | `.connectors(*objs)` | [connector tools](/agents/connectors) | | `.bash_timeout(secs)` | bash sandbox timeout, default 600 | | `.sandbox(network=False)` | cut bash off from the network | | `.base_url(url)` | custom endpoint | | `.api_key(key)` | override the environment key | | `.headers(mapping)` | extra HTTP headers on every model request | | `.loop(fn)` | [replace the loop](/agents/custom-loop) | | `.run(context=, client=)` | execute, yielding events | ## Next steps Built-in tools, custom handlers and approvals. Point an agent at vLLM, SGLang or a private endpoint. # Agents Source: https://docs.cycls.com/agents/overview A chat product in one decorator: managed LLM loop, tools, files, history and a web interface. An agent is an async generator that receives a `context` and yields events. Cycls wraps it in a web application, a streaming protocol, per-user storage and a managed model loop. ```python agent.py theme={null} import cycls llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .system("You are a helpful assistant.") .allowed_tools(["Bash", "Editor", "WebSearch"]) ) @cycls.agent( image=cycls.Image().copy(".providers.env", ".env"), web=cycls.Web().auth(cycls.Clerk()).title("My Agent"), volumes={"/workspace": cycls.Volume("my-agent")}, ) async def my_agent(context): async for ev in llm.run(context=context): yield ev ``` ```bash theme={null} cycls run my_agent.py # localhost:8080 cycls deploy my_agent.py # https://my-agent.cycls.ai ``` ## The decorator ```python theme={null} @cycls.agent(name=None, image=None, web=None, volumes=None, memory="1Gi") ``` | Argument | Meaning | | --------- | --------------------------------------------------------------------------------- | | `name` | deployment name and subdomain. Defaults to the function name | | `image` | [`cycls.Image`](/build/images) for packages and bundled files | | `web` | [`cycls.Web`](/web/interface) for interface, auth and branding | | `volumes` | mount paths to [`cycls.Volume`](/build/volumes). A `/workspace` entry is required | | `memory` | container memory, for example `"1Gi"`, `"4Gi"` | Agents keep chats, files and credentials under `/workspace`, so the decorator raises if no volume is mounted there. Locally, `cycls run` ignores volumes and your code sees the local filesystem. ## What the managed loop does `llm.run(context=context)` is the default loop. Yielding its events through your body is all a normal agent needs. Text, reasoning and tool-call deltas from Anthropic natively and from any OpenAI-compatible endpoint. Built-in tools and your handlers, run in parallel where the provider sends a batch, with results fed back to the model. Transient provider failures are retried with backoff. Context overflow triggers one compaction and a replay. At 70% of the window, old tool results are cleared, and older turns are summarized when that is not enough. The saved transcript stays whole. The user's turn is written to disk before the model is called, so a dropped connection never loses it. Every turn logs tokens and cost when `.price()` is set, queryable with `cycls cost` and `cycls sql`. ## A turn, end to end ```mermaid theme={null} sequenceDiagram participant U as Browser participant A as Agent body participant L as Managed loop participant M as Model participant T as Tools U->>A: POST / with messages A->>L: llm.run(context=context) L->>L: load session, ingest attachments, build system prompt L->>M: stream request M-->>L: thinking, text, tool calls L->>T: run tool batch T-->>L: results L->>M: continue with results M-->>L: final text L-->>A: events A-->>U: SSE stream ``` ## Yielding your own events The body is ordinary Python, so you can emit anything before, during or after the loop. Events are plain dicts, and strings are markdown text. ```python theme={null} @cycls.agent(web=web, volumes={"/workspace": chats}) async def my_agent(context): if context.user.plan == "u:free_user" and await over_quota(context): yield {"type": "callout", "callout": "Free limit reached.", "style": "warning"} yield {"type": "ui", "action": "open_plan_modal"} return yield {"type": "status", "status": "Thinking about it"} async for ev in llm.run(context=context): yield ev yield "\n\n_Answered by My Agent._" ``` See [Streaming components](/agents/streaming) for every event type. ## Watching the loop Events are dicts, so a plain check is enough to react without changing behavior. ```python theme={null} async for ev in llm.run(context=context): if isinstance(ev, dict) and ev.get("type") == "callout" and ev.get("style") == "error": alert_ops(ev["callout"]) yield ev ``` `cycls.to_ui(ev)` still appears in older examples. It is now an identity function kept for backwards compatibility, so `yield ev` and `yield cycls.to_ui(ev)` do the same thing. ## Adding HTTP routes An agent is also a web service. Use `.server` for webhooks, health checks and OAuth callbacks, and `Depends(my_agent.auth)` to protect a route with the same identity the chat endpoint uses. ```python theme={null} from fastapi import Depends @my_agent.server.api_route("/webhook", methods=["POST"]) async def webhook(request): payload = await request.json() return {"ok": True} @my_agent.server.api_route("/profile", methods=["GET"]) async def profile(user=Depends(my_agent.auth)): return {"id": user.id, "plan": user.plan} ``` ## Next steps Providers, reasoning, budgets and pricing. Built-in tools and your own handlers. Messages, users, workspaces and per-request switches. Replace the default loop while keeping the kit. # Sandbox and trust model Source: https://docs.cycls.com/agents/sandbox What isolates the bash tool from your secrets, what that isolation covers, what it does not, and the settings that close the remaining gaps. This page is for developers deploying an agent that runs shell commands on behalf of untrusted users. It states the trust boundaries, what is verified, and what is explicitly not covered. ## Trust inputs | Party | Trust | Reasoning | | -------------------------- | --------- | ------------------------------------------------------------------ | | Agent developer | trusted | you configured the image, wrote the handlers and own the workspace | | End user and their prompts | untrusted | prompt injection is the primary threat | Every control below is designed against prompt injection, not against a malicious developer. ## The layers ``` host → Docker container (agent runtime) → bubblewrap sandbox (bash tool) ``` The agent's Python process holds secrets as environment variables: provider keys, your own `.env` contents, and any credential your handlers use. The purpose of the bubblewrap layer is to keep a prompt-injected shell command away from all of them. Inside the sandbox: * The root filesystem is read-only. The user's workspace is the only writable path, bound at `/workspace`. * `/app` and `/tmp` are ephemeral tmpfs, which masks the provider dotenv file baked into the image. * `.db/` is masked, so the shell cannot read the chat store. The `read` and `edit` tools reject those paths as well. * The environment is cleared and repopulated with `PATH`, `HOME`, `TERM` and `LANG` only. * A user namespace is unshared, which is what blocks reading another process's `/proc//environ`, memory or root. * The cloud metadata address range is blocked by an `LD_PRELOAD` shim that intercepts `connect()` for `169.254.0.0/16` and IPv6 link-local ranges. ## Verified behavior These are the checks the project runs against a live sandbox. | Attempt | Result | | --------------------------------------------------------- | ---------------------------------------- | | Read the parent process environment through `/proc` | denied | | Grep every `/proc/*/environ` for a planted key | no match outside the shell's own process | | Reach another tenant's files through `/proc//root` | denied | | List `/workspace` | only the current tenant's directory | | Read `/app/.env` | empty, masked by tmpfs | | Resolve or fetch the metadata server with network enabled | connection refused | | Any network call with `network=False` | fails, no egress | ## Network ```python theme={null} llm = cycls.LLM().sandbox(network=True) # default llm = cycls.LLM().sandbox(network=False) # no egress from bash ``` Network is on by default because most agents need `curl`, `pip` and `git`. A prompt-injected command can then send anything it can read to an arbitrary host. **Turn it off when the agent does not need it.** With `network=False` the sandbox gets a fresh network namespace and no egress at all. Web search still works, because it runs outside the sandbox. ## What the metadata block does not cover The `LD_PRELOAD` shim stops every program that uses the C library, which covers what a model writes in practice. It does not stop: * statically linked binaries, since they never load the shim * direct syscall invocation, for example through `ctypes` * inline assembly * a child process launched after `unset LD_PRELOAD` All four require deliberate intent, not an accidental injection. If your threat model includes users who read the SDK source and craft bypasses, the architectural answer is a separate deployment per tenant, where a stolen credential reaches only that tenant's own data. ## Deployment isolation Deployments under one account share a trust domain: code in one can reach another's workspace storage. For hard isolation between production and experiments, or between clients, deploy from a separate organization. Each organization is its own tenant with its own boundary. ## Local development caveat The sandbox binds the host root read-only. In production that is the runtime container's filesystem, which holds no host secrets. When you run an agent directly on your machine without Docker, local files such as personal credential stores become readable from the sandbox. Use `cycls run`, which wraps the agent in Docker, when exercising an agent that accepts untrusted input with network-enabled bash. Or keep `network=False`, which removes the exfiltration path. ## Approvals Isolation bounds what a command can reach. Approvals decide whether it runs at all. Every tool call is classified before it runs: | Risk | Behavior | | ----------- | ---------------------------------- | | read | always runs | | write | follows the composer's Auto switch | | destructive | asks in both modes | `rm` and `rmdir` are not destructive by default, because the sandbox moves deletions to a thirty day trash. Commands with nothing behind them, such as `shred`, `mkfs`, `find -delete`, `dd` with an `of=`, `drop table`, `git push --force` and a redirect into a disk device, always ask. `2>/dev/null` is not one of them. A bash command counts as a read only when every command in the line reads, such as `ls` or `grep`, and nothing is redirected into a file. So `ls; touch notes.txt` is a write. See [Connectors and MCP](/agents/connectors#approvals) for per-tool policy and organization level switches. ## Recommended settings for untrusted users ```python theme={null} llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"]) .sandbox(network=False) .bash_timeout(120) ) ``` 1. Turn off sandbox network access unless the agent needs it. 2. Keep deploy credentials out of the image by splitting `.env` and `.providers.env`. 3. Put anything that must not be readable by a shell outside the workspace, for example behind a [connector](/agents/connectors) or a deployed function. 4. Use a separate organization for tenants that must not share a trust domain. ## Next Replace the default loop and keep the building blocks. # Streaming components Source: https://docs.cycls.com/agents/streaming Yield strings for markdown or dicts for rich components: thinking bubbles, tables, code, callouts, images and client actions. An agent body yields two kinds of value. Strings render as markdown. Dicts render as components. Both stream as they are produced, so the interface fills in while the model is still working. ```python theme={null} @cycls.agent(web=web, volumes={"/workspace": chats}) async def demo(context): yield {"type": "status", "status": "Checking the fleet"} yield "Here is what I found:\n\n" yield {"type": "table", "headers": ["Server", "Status", "CPU"]} yield {"type": "table", "row": ["web-1", "Online", "45%"]} yield {"type": "table", "row": ["web-2", "Offline", "0%"]} yield {"type": "code", "code": "systemctl restart web-2", "language": "bash"} yield {"type": "callout", "callout": "web-2 needs attention.", "style": "warning"} ``` ## Component reference | Type | Required keys | Behavior | | ---------- | ---------------------------------- | ------------------------------------------------------------ | | `text` | `text` | accumulates into one block. A bare string is the same thing | | `thinking` | `thinking` | accumulates into a collapsible bubble | | `code` | `code`, `language` | accumulates into one block | | `table` | `headers` or `row` | renders row by row | | `callout` | `callout`, `style` | one card. `style` is `info`, `warning`, `error` or `success` | | `status` | `status` | a single line that replaces the previous status | | `image` | `src`, optional `alt`, `caption` | one image | | `sources` | `sources` | citation chips from `[{title, url, snippet}]` | | `step` | `step`, optional `tool_name`, `id` | a tool step line | | `ui` | `action` | a client action, not rendered and not stored | Raw HTML strings pass through, which is the escape hatch for anything the components do not cover. ## Thinking Consecutive `thinking` yields append to the same bubble until a different type arrives. Provider reasoning deltas map here automatically, so extended thinking from Anthropic and `delta.reasoning` from OpenAI-compatible endpoints both show up without any work on your side. ```python theme={null} yield {"type": "thinking", "thinking": "Checking the invoice totals "} yield {"type": "thinking", "thinking": "against last month."} yield "The totals match." ``` ## Tables Send the header row once, then one `row` per record. Rows render as they arrive, so a slow query shows partial results instead of nothing. ```python theme={null} yield {"type": "table", "headers": ["Region", "Revenue"]} for region, revenue in await query(): yield {"type": "table", "row": [region, f"{revenue:,.0f}"]} ``` ## Client actions A `ui` event triggers something in the client. Nothing is rendered in the conversation and nothing is kept in history. ```python theme={null} if await over_quota(context.user): yield {"type": "callout", "callout": "Free tier limit reached.", "style": "warning"} yield {"type": "ui", "action": "open_plan_modal"} return ``` | Action | Fields | Effect | | ----------------- | ----------------------- | ---------------------------------------------------------------------------------- | | `open_plan_modal` | none | opens pricing. The client picks user or organization plans based on the active org | | `open_canvas` | `path`, optional `name` | opens a workspace file in the canvas viewer | | `suggest` | `text` | one follow-up chip above the composer | | `ask` | `questions` | a question card above the composer, up to three questions | The `Canvas`, `Suggest` and `Ask` [built-in tools](/agents/tools) fire these for you. Yield them directly only from a custom loop. One difference worth knowing: when the `Ask` tool fires, the turn ends, because the next user message carries the answers. ## Errors An unhandled exception inside the body becomes a callout with a short reference id, and the full traceback goes to structured logs. ``` Something went wrong. Reference: abc12345 ``` Look it up with the reference: ```bash theme={null} cycls logs my-agent --query 'jsonPayload.error_id="abc12345"' ``` Errors the loop handles itself, such as a rate limit retry or a tool timeout, are shown as callouts and are not logged as failures. ## Next What the body receives on every turn. # Tools Source: https://docs.cycls.com/agents/tools Enable built-in tools by name, or register your own Python handlers. Both stream into the same UI. Tools come from two places. Built-in tools ship with the SDK and are enabled by name. Custom tools are JSON schemas you define with async handlers you write. ```python theme={null} llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .allowed_tools(["Bash", "Editor", "WebSearch", "Canvas"]) # built in .tools(MY_TOOLS) # your schemas .on("create_ticket", create_ticket) # your handler ) ``` ## Built-in tools Each name enables one or more tools and brings its own prompt guidance, so enabling it is the only switch you need. | Name | What the model gets | | ----------- | ----------------------------------------------------------------------------------------- | | `Bash` | `bash`: run shell commands in a sandbox rooted at the workspace | | `Editor` | `read` and `edit`: view and modify workspace files | | `WebSearch` | `web_search` and `web_fetch`, or the provider's native search | | `Browser` | `browser`: drive a real Chrome session, only offered when a browser service is configured | | `DataBase` | `database`: a per-user key-value store with prefix scans | | `Canvas` | `canvas`: open a finished file in the side panel viewer | | `Apps` | `build_app`: bundle a source folder into an installable mini app | | `Suggest` | `suggest`: one follow-up chip above the composer | | `Ask` | `ask`: up to three questions on one card, which ends the turn | Runs inside a `bubblewrap` sandbox with the user's workspace bound at `/workspace` and a sanitized environment. The working directory is the workspace, scratch belongs in `.tmp/`, and `/tmp` is per-command and discarded. ```python theme={null} llm = cycls.LLM().allowed_tools(["Bash"]).bash_timeout(600).sandbox(network=True) ``` Network access is on by default so `curl`, `pip` and `git` work. A prompt injection can exfiltrate anything the sandbox can read, so turn it off when the agent does not need it: ```python theme={null} llm = cycls.LLM().allowed_tools(["Bash"]).sandbox(network=False) ``` `read` renders files, including PDFs page by page and images for vision models. `edit` creates and modifies files. The model is told to use these rather than `cat` or `sed`, which keeps writes inside the safety checks and off the output token budget. One `read` returns at most 50,000 characters and ends with the line to continue from, so a large file or attachment arrives in pages instead of filling the context window. Paths are workspace relative. Traversal outside the workspace is refused, and so are the reserved internal directories. ```python theme={null} llm = cycls.LLM().allowed_tools(["WebSearch"]).web_search("brave") ``` `brave` is the portable pair, `web_search` plus `web_fetch`. It works on every provider and needs `BRAVE_API_KEY`. Results arrive in the UI as one `sources` row of citation chips at the end of the answer. `native` uses the provider's server-side search, which is Anthropic only today. A real Chrome session driven step by step: `open`, `read`, `click`, `type`, `press`, `back`, `evaluate`, `screenshot`, `download`. The page persists between calls in a turn, so logins and multi-step forms work. The browser itself runs in a shared service, not in your image. Configure it with environment variables, and the tool is not offered when they are unset: ```bash theme={null} BROWSER_PROVIDER=cycls BROWSER_URL=https://cycls-browser.cycls.ai BROWSER_SECRET=your_shared_secret ``` A per-user JSON key-value store with `get`, `put`, `delete` and `scan`. It is private to the user: no teammate and no app can read it. Use it for agent memory across chats, such as preferences and task progress. Users manage it in the web client under Settings, General, Memory. Each entry in the active workspace shows as a plain-text note they can edit or delete. Turning Memory off removes the tool from their chats in every workspace and keeps what was saved. Anything a teammate or an app needs to read belongs in a workspace file or under `apps//data/` instead. `Canvas` opens one finished deliverable in the side panel. It renders markdown, HTML, PDF, images, audio, video, code, CSV, spreadsheets and 3D models. Office formats render read-only through the office service, and anything else falls back to a download card. `Apps` lets the agent bundle a source folder into a single self-contained HTML file installed in the Apps tab. Apps run under a strict CSP with no external fetch, and reach their own data through a small bridge, so a dashboard stays live without holding any key. `Suggest` shows one follow-up chip above the composer, written in the user's voice. `Ask` puts up to three questions on one card and ends the turn, with the answers arriving as the next user message. The options are shortcuts, not a gate: the user can type anything instead. Users can switch both off in Settings, and `context.disabled_tools` reflects that for the turn. A user can switch a tool off in Settings. `LLM.run()` drops those tools for the turn. A switch can never enable a tool the operator did not allow. ## Custom tools A tool schema is a plain dict. A handler is an async function. The return value goes to the UI stream and to the model as the `tool_result`, so one return covers both destinations. ```python theme={null} TOOLS = [ { "name": "create_ticket", "description": "Open a support ticket for the current user.", "inputSchema": { "type": "object", "properties": { "title": {"type": "string"}, "priority": {"type": "string", "enum": ["low", "high"]}, }, "required": ["title"], }, } ] async def create_ticket(args): ticket = await support.create(args["title"], args.get("priority", "low")) return f"Created ticket {ticket.id}" llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .tools(TOOLS) .on("create_ticket", create_ticket, label=lambda i: i["title"]) ) ``` ### Handler signature A handler takes the tool input, and optionally a second argument with request context. ```python theme={null} async def create_ticket(args, ctx): ws = ctx.workspace # the caller's workspace user = ctx.user # the authenticated user return f"Created for {user.id}" ``` ### Presentation `.on()` controls how the call appears in the UI. | Argument | Effect | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `label` | a function from input to a string, rendering the step line like `Bash(command)`. Defaults to the first string value in the input | | `icon` | an image URL shown where a connector logo goes | | `details=True` | the step opens into Request and Response panes, and the result goes there instead of into the chat. The model still receives all of it | ```python theme={null} llm = cycls.LLM().on( "search_orders", search_orders, label=lambda i: f"orders for {i['email']}", icon="https://example.com/logo.png", details=True, ) ``` ### Returning UI components A handler can return a component dict instead of text, and it renders in the conversation. ```python theme={null} async def render_chart(args): path = await make_chart(args["series"]) return {"type": "image", "src": path, "caption": "Revenue by month"} ``` ## Reserved names `skill` and `find_tools` are reserved by the loop. `skill` loads a [skill](/agents/knowledge#skills) on demand, and `find_tools` loads a connector's tools when the model needs them. ## Next steps A complete tool with a handler, a label and a UI return. OAuth grants, API keys, MCP servers and per-tool approvals. # Get User Subscription Source: https://docs.cycls.com/api-reference/billing/get-user-subscription /api-reference/openapi.json get /v1/user/subscription Fetches the billing subscription details for the authenticated user from Clerk. # Check Name Availability Source: https://docs.cycls.com/api-reference/deployments/check-name-availability /api-reference/openapi.json get /v1/deployment/check-name Check if a deployment name is available for the authenticated user. Returns whether the name can be used and a reason if not. # Delete a Service Source: https://docs.cycls.com/api-reference/deployments/delete-a-service /api-reference/openapi.json post /v1/deployment/delete # Deploy Source: https://docs.cycls.com/api-reference/deployments/deploy /api-reference/openapi.json post /v1/deploy Deploy a service. Returns NDJSON stream with progress updates. Post the source as `source_archive`, the tar.gz, up to 100 MiB. Ship large files via volumes (`cycls volume put`), not the source archive. `timeout` is how long a single request to the deployed service may run raise it for long compute, up to Cloud Run's 3600s ceiling. `cpu` is raised automatically if `memory` requires more than you asked for. `concurrency` is how many requests one instance serves at once. Leave it high for I/O-bound apps; set it to 1 for compute, so each call gets its own instance. `max_instances` caps how many instances the service may scale to. 0 (default) leaves scaling to the platform. `volumes` maps mount paths to volumes (JSON), e.g. `{"/data": "shared"}` or `{"/data": {"name": "shared", "read_only": true, "sub_path": "v1"}}`. Required, `{}` declares no storage; the deploy is a complete declaration. Unknown volumes are created (announced in the stream). `schedule` fires the deployed function on a cron: `{"cron": "0 3 * * *", "timezone": "Asia/Riyadh"}`. Empty or omitted removes any schedule, the same complete-declaration rule. Events: `BUILDING` → `DEPLOYING` → `DONE` (or `ERROR`) # List Service Source: https://docs.cycls.com/api-reference/deployments/list-service /api-reference/openapi.json get /v1/deployment/list Lists all services for the authenticated user or organization. # Cloud API Source: https://docs.cycls.com/api-reference/introduction The REST API behind the CLI: deploy services, manage volumes, read logs and run SQL. Authenticate with an API key or a bearer token. `https://api.cycls.ai` is the control plane. The [CLI](/ship/cli) is a client for it, so anything `cycls` can do, your own tooling can do too. Two different hostnames. `api.cycls.ai` is this platform API, the one the CLI calls to build, deploy and manage things. Your own deployments are served at `https://.cycls.ai`, and their endpoints are in the [Agent HTTP API](/reference/agent-api). ## Authentication Every endpoint accepts either header. ```bash API key theme={null} curl https://api.cycls.ai/v1/deployment/list \ -H "X-API-Key: $CYCLS_API_KEY" ``` ```bash Bearer token theme={null} curl https://api.cycls.ai/v1/deployment/list \ -H "Authorization: Bearer $TOKEN" ``` Get an API key from [Cycls Cloud](https://cloud.cycls.com). Requests are scoped to the account that owns the key, so listings return only your resources. Every endpoint page has a live playground. Paste your key into the authorization field, fill the parameters, and send the request against `https://api.cycls.ai` without leaving the page. Start with [list deployments](/api-reference/deployments/list-service), which is read only. ## What the API covers | Area | Endpoints | | ----------- | ------------------------------------------------------------------------------------- | | Deployments | check a name, deploy, list, delete | | Volumes | create, list, delete, list objects, delete an object, signed upload and download URLs | | Logs | fetch log entries, run SQL over logs and billing | | Billing | read the account subscription | ## Deploying `POST /v1/deploy` takes a multipart form with the source archive and streams NDJSON progress events back. ```bash theme={null} curl -X POST https://api.cycls.ai/v1/deploy \ -H "X-API-Key: $CYCLS_API_KEY" \ -F "function_name=hello" \ -F "source_archive=@source.tar.gz" \ -F "volumes={\"/workspace\": \"hello-chats\"}" ``` Events arrive in order: `BUILDING`, then `DEPLOYING`, then `DONE` or `ERROR`. Two fields behave as complete declarations rather than patches: * `volumes` maps mount paths to volumes. `{}` declares no storage. Unknown volumes are created and the stream announces it. * `schedule` sets the cron. Omitting it or sending an empty value removes any existing schedule. The archive is capped at 100 MiB. Ship large files through a volume instead. ## Moving volume data Uploads and downloads do not pass through the API. Request a signed URL, then transfer directly to storage. ```bash theme={null} curl -X POST https://api.cycls.ai/v1/volume/upload \ -H "X-API-Key: $CYCLS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"volume": "training-data", "object": "models/model.bin"}' ``` The response carries a URL you `PUT` the bytes to. `cycls volume put` and `cycls volume get` do exactly this, which is why file size is effectively unlimited. ## Rate limits and errors | Status | Meaning | | ------ | ------------------------------------------------------ | | `401` | missing or invalid credentials | | `403` | the key does not own the resource | | `404` | no such deployment or volume | | `422` | validation error, with the offending field in the body | | `503` | a backing service is unavailable, retry | ## Endpoint reference Every endpoint, with parameters, schemas and a request builder, is in the pages under **Endpoints** in the sidebar. The specification is generated from the live service and mirrored at [api.cycls.ai/openapi.json](https://api.cycls.ai/openapi.json). # Get Service Logs Source: https://docs.cycls.com/api-reference/logs/get-service-logs /api-reference/openapi.json post /v1/deployment/logs Fetch logs from Cloud Logging for a specific service. Use `cursor` from response as `since` to poll for new logs. # SQL over your logs and billing Source: https://docs.cycls.com/api-reference/logs/sql-over-your-logs-and-billing /api-reference/openapi.json post /v1/sql Run SQL against your own logs and billing data. Two views are pre-set in your default dataset: - `logs` , Cloud Logging entries for your deployments - `billing`, Cloud Billing rows for your deployments Each tenant gets their own BigQuery dataset and service account; cross-tenant references fail at the IAM layer. # Create a Volume Source: https://docs.cycls.com/api-reference/volumes/create-a-volume /api-reference/openapi.json post /v1/volume/create Explicitly create a volume (idempotent). Deploys referencing an unknown volume also create it; content operations never do. # Delete a Volume Source: https://docs.cycls.com/api-reference/volumes/delete-a-volume /api-reference/openapi.json post /v1/volume/delete Refuses while any deployment mounts it. Bucket deletion is backstopped by GCS soft delete. # Delete an Object from a Volume Source: https://docs.cycls.com/api-reference/volumes/delete-an-object-from-a-volume /api-reference/openapi.json post /v1/volume/delete-object # Get a Signed Download URL for a Volume Object Source: https://docs.cycls.com/api-reference/volumes/get-a-signed-download-url-for-a-volume-object /api-reference/openapi.json post /v1/volume/download # Get a Signed Upload URL for a Volume Object Source: https://docs.cycls.com/api-reference/volumes/get-a-signed-upload-url-for-a-volume-object /api-reference/openapi.json post /v1/volume/upload Strict: the volume must already exist, uploads never create one. # List Objects in a Volume Source: https://docs.cycls.com/api-reference/volumes/list-objects-in-a-volume /api-reference/openapi.json get /v1/volume/objects # List Volumes Source: https://docs.cycls.com/api-reference/volumes/list-volumes /api-reference/openapi.json get /v1/volume/list Volumes with the deployments currently mounting them, read live from the service templates, the sole store of attachments. # Apps Source: https://docs.cycls.com/build/apps Return a FastAPI application and get a URL, sign-in, and per-user storage. Everything a function can do, an app can do too. `@cycls.app` is a function that returns an ASGI application. Cycls serves it, gives it a URL, and wires in identity and storage. ```python notes.py theme={null} import cycls @cycls.app(auth=cycls.Clerk(), volumes={"/workspace": cycls.Volume("notes")}) def notes(): from fastapi import FastAPI app = FastAPI() @app.get("/me") async def me(user=notes.auth): return {"id": user.id, "plan": user.plan} @app.post("/notes") async def create(body: dict, ws=notes.workspace): db = cycls.DB(ws) await db.put(f"docs/{body['id']}", body) return body return app ``` ```bash theme={null} cycls run notes.py # localhost:8080, reload on save cycls run notes.py --remote # https://dev-notes.cycls.ai, hot swap on save cycls deploy notes.py # https://notes.cycls.ai ``` ## The decorator ```python theme={null} @cycls.app(name=None, image=None, volumes=None, auth=None, memory="1Gi") ``` | Argument | Meaning | | --------- | -------------------------------------------------------------------- | | `name` | deployment name and subdomain, defaults to the function name | | `image` | [`cycls.Image`](/build/images) for packages and bundled files | | `volumes` | mount paths to [volumes](/build/volumes) | | `auth` | `cycls.Clerk()` or `cycls.JWT(...)`, see [Authentication](/web/auth) | | `memory` | container memory | The body runs inside the container at startup. Import heavy dependencies there, not at module level, so your local process stays light. ## Identity With `auth=` set, two dependencies become available on the decorated object. ```python theme={null} @app.get("/me") async def me(user=notes.auth): # authenticated User, or 401 return user @app.get("/files") async def files(ws=notes.workspace): # that user's storage scope return [p.name for p in ws.root.iterdir()] ``` `user` carries `id`, `org_id`, `org_slug`, `org_role`, `org_permissions`, `plan` and `features`. See [Authentication](/web/auth) for providers and claims. ## Storage `notes.workspace` resolves a per-user scope on the volume mounted at `/workspace`. Two things live there: ```python theme={null} @app.post("/notes") async def create(body: NoteIn, ws=notes.workspace): db = cycls.DB(ws) await db.put(f"docs/{uuid4().hex[:12]}", body.dict()) return {"ok": True} @app.get("/notes") async def list_notes(ws=notes.workspace): return [d async for _, d in cycls.DB(ws).items(prefix="docs/")] ``` Atomic per key, prefix scans, JSON values. Backed by the volume, so it survives redeploys. ```python theme={null} @app.get("/report") async def report(ws=notes.workspace): path = ws.root / "report.csv" return path.read_text() ``` `ws.root` is a `Path` scoped to that user. Write files there and they persist exactly like any other volume content. Both need a volume at `/workspace`. Without one the error arrives on first use, not at deploy, so declare it whenever the app touches storage. ## Serving a front end Bundle the HTML with the image and return it from a route. ```python theme={null} from pathlib import Path HTML = str(Path(__file__).parent / "index.html") @cycls.app(image=cycls.Image().copy(HTML, "index.html"), auth=cycls.Clerk()) def dashboard(): from fastapi import FastAPI from fastapi.responses import HTMLResponse app = FastAPI() @app.get("/") async def index(): pk = dashboard._auth_provider.resolve(dashboard.prod).get("pk", "") return HTMLResponse(Path("index.html").read_text().replace("__CLERK_PK__", pk)) return app ``` The publishable key is resolved at request time, so the same file works locally and in production without a build step. ## Running untrusted commands `cycls.Sandbox` is a fluent wrapper around `bubblewrap` for executing commands with a read-only root, a cleared environment and no network unless you ask. ```python theme={null} sandbox = ( cycls.Sandbox() .setenv(PATH="/usr/local/bin:/usr/bin:/bin") .network(False) .timeout(120) ) @app.post("/run") async def run(cmd: str, ws=terminal.workspace): result = await sandbox.bind(str(ws.root), "/workspace").chdir("/workspace").run( ["bash", "-lc", cmd] ) return {"output": result.output, "code": result.code} ``` | Method | Effect | | ---------------------------- | ---------------------------------------------------------- | | `.bind(src, dst)` | read-write bind mount | | `.ro_bind(src, dst)` | read-only bind mount | | `.setenv(**vars)` | set environment variables | | `.chdir(path)` | working directory | | `.network(on)` | allow network, off by default | | `.timeout(seconds)` | kill after this long | | `await .run(argv, env=None)` | returns `stdout`, `stderr`, `code`, `timed_out`, `.output` | ## Programmatic control ```python theme={null} notes.local() # Docker with hot reload notes.local(watch=False) # Docker, no watcher notes.deploy() # production ``` For `python notes.py` to serve, guard it the usual way: ```python theme={null} if __name__ == "__main__": notes.local() ``` ## Next The chat layer on top of apps: a managed model loop, tools and a web UI. # Cron Source: https://docs.cycls.com/build/cron Add one argument to a function and the platform calls it on a schedule. No scheduler to run, nothing to drift. A deployed function can carry a schedule. The platform calls it, so there is no worker of yours to keep alive. ```python heartbeat.py theme={null} import cycls reports = cycls.Volume("daily-reports") @cycls.function( schedule=cycls.Cron("0 3 * * *", timezone="Asia/Riyadh"), volumes={"/reports": reports}, ) def nightly(): from datetime import datetime, timezone now = datetime.now(timezone.utc) with open(f"/reports/{now:%Y-%m-%d}.log", "a") as f: f.write(f"{now.isoformat()} ran\n") ``` ```bash theme={null} cycls deploy heartbeat.py # [DEPLOYING] Scheduled: 0 3 * * * (Asia/Riyadh) # [DONE] Deployment complete! ``` Pair a schedule with a [volume](/build/volumes) so the output has somewhere durable to land. A scraper feeding a dashboard is the usual shape. ## Writing the schedule ```python theme={null} cycls.Cron("0 3 * * *") # daily at 03:00 UTC cycls.Cron("*/15 * * * *") # every fifteen minutes cycls.Cron("0 6 * * 1", timezone="Asia/Riyadh") # Mondays at 06:00 Riyadh ``` Five-field unix cron with IANA timezone names, UTC by default. Both are validated at deploy time, so a typo is an error rather than a schedule that never fires. ## Source is the truth The schedule exists because the line exists. Delete `schedule=` and redeploy and the schedule is gone, which the deploy output confirms. `cycls rm` removes it with the deployment. There is no pause button and no separate object to keep in sync. ## What can be scheduled Bare functions only, the kind that deploy as named endpoints. Two cases fail at import with the fix in the message: * an app or agent with `schedule=`, since they serve HTTP. Schedule a function that calls them instead. * a function that takes `port`, since the port contract deploys a server and a schedule cannot fire one. ## Semantics Failed runs are retried, so write idempotent output. Date-keyed files like the example above make a double fire a harmless overwrite. A run slower than its interval does not block the next one. If two runs must never race, take a lock file on the volume. A single scheduled run has a thirty minute ceiling. Split longer work into slices, or have the scheduled function fan out with `.map()`. `cycls logs ` shows each one, with the same structured logging as any other call. ## Next Serve a FastAPI application with auth and per-user storage. # Functions Source: https://docs.cycls.com/build/functions Turn any Python function into a container that runs on your laptop, in the cloud, or on a schedule, without writing a Dockerfile. `@cycls.function` is the bottom layer. It takes a Python function, packages it with the dependencies you declare, and runs it wherever you point it. ```python simulate.py theme={null} import cycls @cycls.function(image=cycls.Image().pip("numpy")) def simulate(n=1_000_000): import numpy as np pts = np.random.rand(int(n), 2) return float(4 * ((pts ** 2).sum(axis=1) <= 1).mean()) ``` Three verbs decide where it runs: ```python theme={null} simulate.run(1000) # your Docker simulate.remote(1000) # Cycls Cloud, the code you are holding simulate.deploy() # Cycls Cloud, frozen and callable by name ``` ## How the code gets there The function is serialized with cloudpickle, bytecode, closures and captured variables included, then executed inside a container built from your `Image`. The same pickle runs locally and in the cloud, so what you tested is what ships. Two version rules keep that safe, and both are enforced for you: * The container's Python matches your host's major and minor version. * The container's cloudpickle matches your host's exact version. Functions defined in the file you deploy travel by value. Functions imported from another module travel by reference, so the container needs that module. Bundle it with `cycls.Image().copy("helpers.py")`, and anything copied lands in `/app`, which is on `sys.path`. ## Running locally ```python theme={null} result = simulate.run(1000) ``` The image is built or reused, the function executes, and the return value comes back. Print statements stream to your terminal while it runs. To look around inside the exact environment the function sees: ```bash theme={null} cycls shell simulate.py # Entering cycls/simulate:730f149a (exit to leave) root@a1b2c3:/app# ``` ## The development loop ```bash theme={null} cycls run simulate.py # rerun on every save, local Docker cycls run simulate.py --remote # rerun on every save, in the cloud cycls run simulate.py --n 1000 # arguments bind to the signature ``` Trailing `--name value` arguments bind to the function's parameters. Annotated parameters convert through their annotation, so `n: int` gets an int. Unannotated ones are parsed as Python literals, so `--data "[1,2]"` becomes a list. Anything else stays a string. A save during a run queues the next run instead of killing the current one. For anything with several calls, a fan-out, or a mix of local and remote, mark a driver: ```python theme={null} @cycls.local_entrypoint def main(n: int = 1_000_000): print(simulate.remote(n)) print(simulate.map([10, 20, 30])) ``` The entrypoint runs on your machine on every save, and the verbs inside it decide where the work happens. Keep driver calls inside it. A top-level `simulate.remote(...)` fires on every import, including during `cycls deploy`. ## Running in the cloud with current code ```python theme={null} simulate.remote(1_000_000) # one call simulate.map([10**6] * 100) # one call per item, ordered results ``` `remote()` ships the live bytecode to an executor, which is a small service provisioned once per image and shared by every function using that image. The first call for a given image takes about ninety seconds while it provisions. After that, a call costs network plus compute. Edit the function, call again, and the new code runs. There is no redeploy step. `map()` fans one call per item across autoscaled instances and returns results in input order. It raises on the first failure, so return errors as data when you want per-item tolerance: ```python theme={null} @cycls.function(image=cycls.Image().pip("httpx", "beautifulsoup4")) def scrape(url): try: ... except Exception as e: return {"url": url, "error": str(e)} ``` ## Deploying ```bash theme={null} cycls deploy simulate.py # Deployed: https://simulate.cycls.ai # Call it: cycls.remote("simulate")(...) ``` Deploy reads the function's shape. A function that takes a `port` parameter is a server and gets its own URL. A bare function becomes a named endpoint, frozen at deploy time and callable from any machine that has your API key: ```python theme={null} import cycls pi = cycls.remote("simulate")(10_000_000) results = cycls.remote("simulate").map([10**6] * 100) ``` The difference matters. `f.remote()` runs your current code, which is what you want while developing. `cycls.remote("name")` calls what was deployed, which is what a caller without your source needs. ## Serving instead of returning Any function that binds a port is a server: ```python theme={null} @cycls.function(image=cycls.Image().pip("fastapi", "uvicorn")) def api(port): from fastapi import FastAPI import uvicorn app = FastAPI() @app.get("/") async def root(): return {"ok": True} uvicorn.run(app, host="0.0.0.0", port=port) ``` ```bash theme={null} cycls run api.py # http://localhost:8080 cycls deploy api.py # https://api.cycls.ai ``` For a service with auth and per-user storage, use [`@cycls.app`](/build/apps), which is this pattern with the plumbing already done. ## Sizing and limits ```python theme={null} @cycls.function( image=cycls.Image().pip("torch"), cpu=4, memory="8Gi", timeout=1800, concurrency=1, ) def train(epochs): ... ``` | Argument | Meaning | | ---------------- | ------------------------------------------------------------- | | `cpu` | 1, 2, 4, 6 or 8 | | `memory` | `"512Mi"` through `"32Gi"` | | `timeout` | seconds a single request may run, up to 3600 | | `concurrency` | requests one instance serves at once. Set it to 1 for compute | | `volumes` | mount paths to [volumes](/build/volumes) | | `schedule` | a [cron](/build/cron) that fires the deployment | | `python_version` | pin the container's Python, must match your host | ## Keeping state warm The process lives across calls on an instance, so expensive setup can be paid once per instance using a mutable default: ```python theme={null} @cycls.function(image=cycls.Image().pip("fastembed")) def embed(texts, _model={}): if "m" not in _model: from fastembed import TextEmbedding _model["m"] = TextEmbedding("BAAI/bge-small-en-v1.5") return [v.tolist() for v in _model["m"].embed(list(texts))] ``` First call loads the model, every call after is warm. That is a self-hosted embedding API in ten lines. ## Method reference | Method | Meaning | | -------------------------- | --------------------------------------------------- | | `.run(*args, **kwargs)` | execute in local Docker, `port=` serves | | `.remote(*args, **kwargs)` | execute current code in the cloud | | `.map(items, workers=16)` | fan current code across instances, ordered | | `.watch(*args, **kwargs)` | run with file watching | | `.build()` | build the image without running | | `.deploy()` | freeze and publish | | `cycls.remote(name)` | a callable for a deployed endpoint, `.map` included | ## Next Declare packages, system libraries and bundled files. # Images Source: https://docs.cycls.com/build/images Declare pip packages, system libraries, bundled files and build commands in Python. Cycls hashes the result into a cached Docker image. `cycls.Image` is the container declaration. Chain methods to describe what the container needs, then hand it to any decorator with `image=`. ```python theme={null} image = ( cycls.Image() .pip("pandas", "duckdb") .apt("ffmpeg", "libpq-dev") .copy("./utils.py") .copy("./models/", "app/models/") .run("curl -fsSL https://example.com/install.sh | sh") ) @cycls.function(image=image) def process(path): from utils import clean # bundled by .copy() ... ``` ## Methods | Method | What it does | | ---------------------- | ---------------------------------------------------------- | | `.pip(*packages)` | install Python packages from PyPI | | `.apt(*packages)` | install system packages with apt-get | | `.copy(src, dst=None)` | bundle local files or directories, `dst` defaults to `src` | | `.run(command)` | run a shell command during the build | | `.rebuild()` | skip the Docker cache for this image | Every call returns a new `Image`, so a base image can be branched without affecting the original. ```python theme={null} base = cycls.Image().pip("httpx") worker = base.pip("pandas") # base is unchanged ``` ## Bundling files Copied paths land in `/app`, which is on `sys.path`, so bundled modules import normally inside the function body. ```python theme={null} ( cycls.Image() .copy("./utils.py") # same path inside the container .copy("./models/", "app/models/") # relocate .copy("/home/me/configs/app.json") # absolute source ) ``` **Splitting secrets by audience.** Keep `CYCLS_API_KEY` in `.env` on your machine and provider keys in `.providers.env`, then rename during the copy so the container sees only what it needs: ```python theme={null} image = cycls.Image().copy(".providers.env", ".env") ``` The agent's dotenv loader finds `.env` at the expected name, and your deploy key never enters the image. ## Caching Cycls hashes the declaration, including the contents of copied files, into a deterministic Docker tag. Identical inputs reuse the cached build. Change one package and only what depends on it rebuilds. The first build of a new image takes a minute or two. After that it is cached anywhere that config appears, on your machine and in the cloud. ```python theme={null} image = cycls.Image().pip("numpy").rebuild() # force a clean build ``` ## What is already in the image You do not need to declare the basics. Apps get FastAPI, hypercorn, PyJWT and cryptography. Agents add the Anthropic and OpenAI SDKs, the MCP client, PDF tooling, `ripgrep`, `jq`, `curl` and fonts. Declare what your own code needs on top of that. ## Public static files Files served at `/public` belong on [`cycls.Web`](/web/interface), not on the image: ```python theme={null} web = cycls.Web().copy_public("./assets/logo.png", "./downloads/") ``` They are then reachable at `https://your-app.cycls.ai/public/logo.png`. ## Next Persistent storage that outlives any single deployment. # Volumes Source: https://docs.cycls.com/build/volumes Named persistent storage you attach to any deployment at any mount path. Data outlives the deployments that use it. A volume is named storage. Create it once, mount it wherever you need it, and it stays alive regardless of what gets deployed or deleted around it. ```python theme={null} data = cycls.Volume("training-data") @cycls.function(volumes={"/data": data}) def crunch(day): import pandas as pd return pd.read_parquet(f"/data/events-{day}.parquet").sum() ``` Inside the container, `/data` is the volume. Files written there survive calls, instances and redeploys, and any other deployment mounting `training-data` sees the same files. ## The mental model A volume is shared files, not a database. Reads and writes are ordinary filesystem operations, which has two consequences worth knowing up front: * Concurrent writes to the same file are last write wins. There is no locking. Give concurrent writers distinct paths, such as one file per user or per run, and the problem disappears. * A file written by one deployment becomes visible to others within seconds, not instantly. ## Creating and attaching Referencing a volume creates it. The first deploy that mentions `training-data` brings it into existence and says so in the deploy output, so a typo shows up as a new volume rather than a silent empty one. To be explicit first: ```bash theme={null} cycls volume create training-data ``` `volumes=` maps mount paths to volumes on any decorator: ```python theme={null} state = cycls.Volume("staging-state") prod = cycls.Volume("app-data") @cycls.app(name="staging", volumes={ "/workspace": state, "/prod": prod.read_only(), # visible, not writable "/users": prod.sub_path("users/123"), # one subtree only }) def staging(port): ... ``` | Modifier | Effect | | ------------------ | ---------------------------- | | `.read_only()` | mount without write access | | `.sub_path("a/b")` | mount only that subdirectory | ## The workspace convention `/workspace` is where apps and agents keep state: chats, per-user files, credentials and the key-value store. Agents require a volume there and the decorator raises without one. Apps need one only if they use `workspace` or `DB`. ```python theme={null} @cycls.agent(volumes={"/workspace": cycls.Volume("my-agent")}) async def my_agent(context): ... ``` Nothing is created implicitly. Storage is exactly what your source declares, and renaming a deployment changes nothing about its data, because the volume reference is the identity. `cycls run` ignores volumes. Locally your code sees the local filesystem, which keeps the development loop fast and offline. ## Moving data in and out The CLI talks to storage directly, so transfers do not proxy through the API and file size is effectively unlimited. ```bash theme={null} cycls volume put training-data ./model.bin models/model.bin cycls volume get training-data outputs/result.parquet . cycls volume ls training-data # contents cycls volume ls training-data models/ # contents under a prefix cycls volume rm training-data models/old.bin cycls volume ls # every volume, with attachments ``` This is how you seed a dataset before anything is deployed, and how you pull results out without writing an endpoint. ## Sharing across deployments Attachment is by name, so a family of deployments can work on one dataset. ```python theme={null} shared = cycls.Volume("pipeline") @cycls.function(volumes={"/pipe": shared}) def extract(day): ... # writes /pipe/raw/.json @cycls.function(volumes={"/pipe": shared}) def transform(day): ... # reads raw/, writes clean/ @cycls.app(name="dashboard", volumes={"/pipe": shared.read_only()}) def dashboard(port): ... # serves clean/, cannot corrupt it ``` One volume, three deployments, no copying. Put a [schedule](/build/cron) on the producer and the pipeline runs itself. For a worked version of this with Parquet and DuckDB, see [Build a data warehouse](/guides/duckdb-warehouse). ## Lifecycle * `cycls rm ` detaches volumes and leaves the data alone. Redeploying the same name reattaches them with files intact. * `cycls volume delete ` is the only way to delete data. It refuses while any deployment has the volume attached, and names them in the error. * Deleted volumes stay recoverable for seven days. ## Structured state on a volume For per-user JSON rather than files, `cycls.DB` is a small key-value store that writes to the workspace volume. ```python theme={null} db = cycls.DB(context.workspace) await db.put("usage/2026-09", {"count": 12}) entry = await db.get("usage/2026-09", {"count": 0}) async for key, value in db.items(prefix="usage/"): ... await db.delete("usage/2026-08") ``` | Method | Meaning | | ---------------------------------- | ----------------------------------------------- | | `await db.get(key, default=None)` | read one key | | `await db.put(key, value)` | write one key, atomic per key | | `await db.delete(key)` | remove a key, trailing slash clears a namespace | | `db.items(prefix=, glob=, limit=)` | async iterator of key and value | | `db.keys(prefix=)` | keys only | | `db.scan(prefix=)` | one round trip listing | ## Next Fire a deployed function on a schedule. # Changelog Source: https://docs.cycls.com/changelog Releases of the Cycls SDK, newest first. Versions match the published packages on PyPI. Every version listed here is published on [PyPI](https://pypi.org/project/cycls/). Upgrade with `pip install -U cycls`. **Tool settings no longer need the secret key.** A person's built-in tool settings, set from the card's Always allow, the tool permissions in Settings or the Memory switch, are stored as plain settings instead of encrypted credentials. They work on every deployment. Before, a deployment without `CYCLS_SECRET_KEY` failed every message from anyone who had clicked Always allow. The key is now needed only for connectors. **A missing key degrades.** Without `CYCLS_SECRET_KEY`, stored connector grants read as absent and a warning is logged, the same as after a key rotation. Storing a grant still fails. **Stricter bash reads.** A command counts as a read only when every command in the line reads and nothing is redirected into a file, so `ls; touch notes.txt` is a write. `find -delete` always asks, because it deletes without going through the trash. Built-in tool settings saved before this release are not carried over, so anyone who chose Always allow is asked again until they choose it once more. **Compaction in two steps.** Past 70% of the context window, the loop first replaces old tool results and long tool arguments with a placeholder, which needs no model call. It summarizes only when that would free less than 25% of the window, and keeps the most recent 30% verbatim either way. The trigger also leaves room for `.max_tokens()`. Clearing never touches a loaded skill, and the system prompt and `AGENT.md` are never cleared or summarized. See [Long conversations](/agents/models#long-conversations). **Cheaper summaries.** A summary is requested as the loop's own next turn, with the same system prompt and tools, so the provider serves the conversation from its cache. A run that ends past the trigger summarizes before it closes, while the answer is being read, so the next message does not wait. A chat that resumes after 20 minutes idle is cleared at a lower bar, since the provider's cache has mostly expired by then. **Bounded reads.** One `read` returns at most 50,000 characters and says where to continue. Attachments go through `read`, so a single large file can no longer overflow the window before the model runs. **Instructions.** `AGENT.md` opens from an Instructions tile in the dock at the bottom of the right panel, in the canvas editor, and no longer appears in Files. An empty file opens with examples to start from. **Memory in Settings.** Settings, General, Memory lists what the `database` tool has saved for the person as plain-text notes they can edit, or delete with Undo. The switch that turns the tool off moved here from Tool permissions. New routes: `GET /memory`, `PUT /memory/{key}` and `DELETE /memory/{key}`. **A markdown editor.** Markdown files edit in a rich editor with a toolbar, a `/` menu, drag handles, checklists and tables. Images and videos added to a document are saved in a `media/` folder beside it, and the markdown view shows them. Each block takes its direction from its own text, so Arabic and English mix. Markdown with math, HTML or footnotes still edits as plain text. **Also in this release** * Every compaction writes a `level=compaction` log record with its tier, reason, size and outcome, and the web client tracks it as `context_compacted`. * A summary that fails, returns no text or runs past five minutes drops the older turns, so the next request still fits. * SGLang's context-full error is recognized, so the loop compacts and replays instead of showing the raw error. * `compact()` in the custom loop kit now takes `keep` and `max_tokens`. `session.compact(provider)` works as before. * Dark mode styles follow the theme chosen in the app, not the operating system. Ships the current web client again. The 0.0.2.142 package carried a browser bundle from before the 0.0.2.141 client changes, so apps could not reach their data through the bridge and Settings showed Connectors on deployments that declare none. Upgrade if you deployed 0.0.2.142. **Fewer approval cards.** A destructive command now has to be an actual invocation, so `shredder.py` or a grep for the word `truncate` no longer asks. `dd` asks only when it names an `of=`. `2>/dev/null` no longer counts as destructive, and a redirect into a disk device now does. **Connectors.** A user connects an account once and the tools that need it get a live credential. `cycls.OAuth2` runs authorization code with PKCE, `cycls.Key` takes a pasted key, and `cycls.Endpoint` accepts a private URL that is itself the credential. Declare them on `Web().connectors()` for the directory and `LLM().connectors()` for the loop. Credentials are encrypted at rest, calls are audited with the grant they acted under, and an organization admin can switch a connector off for everyone. **Per-tool approvals.** Every call is classified as read, write or destructive. Reads always run, writes follow the composer's Auto switch, and destructive calls ask in both modes. An approval binds to the exact arguments shown on the card, so a changed call asks again. Each person can set allow, ask or never per tool. **MCP on every provider.** The harness speaks Streamable HTTP itself, so `cycls.MCP` servers work on any model rather than Anthropic only. Tools are discovered once and cached, and a session opens only when a tool is called. `.server_side()` still hands a server to Anthropic's connector. **Tools load on demand.** A connector contributes one index line to the system prompt, and the model calls `find_tools` to pull in the schemas it needs. Loaded tools stay for the rest of the conversation. **Background runs.** A run now records its own state, survives a dropped connection, and can be stopped from another container. The client watches a run it started, reconnects to it, and shows a background indicator. `Web().on_run()` fires once when a run reaches any terminal status. **Durable turns.** The user's message is written to disk before the model is called, turn writes refuse to overwrite, and a reader normalizes in memory while only the writer repairs on disk. **Browser automation.** Adding `"Browser"` to `allowed_tools` gives the model a real Chrome session: open, read, click, type, press, back, evaluate, screenshot and download, with page state persisting across calls in a turn. Chrome runs in a shared service configured by `BROWSER_URL` and `BROWSER_SECRET`, not in the agent image. Includes per-site proxy routing and a stealth engine. **Office files on the canvas.** Spreadsheets render as an interactive grid and `.docx` as a formatted document, both in the browser. Presentations render as a slide viewer and other office formats as a read-only PDF through the shared render service. Any failure falls back to the download card. **Apps.** The `Apps` tool bundles a source folder into one self-contained HTML file installed in the Apps tab. App data is rows in a dedicated slot with compare-and-swap writes and a 25 MB cap, and an app can call a connected connector's REST API with the credential attached server-side. **Analytics as plugins.** One canonical event pipe in the client fanned out to `cycls.PostHog` and `cycls.GTM`, each scoped by an optional event allowlist. `cycls.OneSignal` adds web push. **Also in this release** * Large tool results spill to a file to compute over instead of being read back as text. * `.tmp/{chat}` is the agent's `TMPDIR`, and `/tmp` fails with an explicit reason. * Tool handlers can declare a second parameter to receive a `ToolContext` with the user, workspace and chat. * Google One Tap on the signed-out page, opt in with `cycls.Clerk(one_tap=True)`. * Search results collapse into one sources row at the end of an answer. * A shared chat forks into the forker's active workspace. * The base image is pinned to bookworm, because the sandbox needs a syscall the newer runtime lacks. * **Mini apps end to end.** A `build_app` tool, a sandboxed bridge scoped to `apps//`, and an Apps launcher. * **Canvas.** Deliverables open while they are being written, tabs share the strip and shrink like a browser's, the rail folds to icons, and the composer keeps working while the agent does. * **Example gallery.** `Web().examples()` turns real share links into cards on the empty chat screen, with an optional tutorial video card. * **Suggest and Ask.** One follow-up chip above the composer, and a question card with up to three questions that ends the turn. * **Apple IAP per product.** `products` takes a mapping of product id to plan, and verification hands back the payload. * Transient 500s from model servers are retried, since capacity failures surface that way. * `web_search` searches before answering, with Brave country and language passthrough. * Provider clients are cached on the full configuration, `.headers()` carries proxy authentication, and unmapped `.thinking()` values print a warning. * **Workspaces.** `Web().workspaces()` gives every user a personal workspace and lets organizations share team workspaces with role-based access. Each workspace is a full context: files, chats, `AGENT.md`, skills and key-value store. The active one is selected per request with the `X-Workspace` header. * **Skills.** `LLM().skills("skills")` ships skill folders with the agent, and any `skills//SKILL.md` in a user's workspace joins the catalog automatically. Only names and descriptions sit in the system prompt. * **Unified reasoning.** `.thinking("low" | "medium" | "high" | "adaptive" | None)` is translated into each vendor's dialect, with `.extra_body()` as the escape hatch. * **Faster import.** `import cycls` went from about 1.0s to about 40ms through lazy imports and private subpackages. * Agent routes cross the pickle boundary as data rather than router instances. * Failures that affect a user surface as a callout with a reference id, while plumbing goes to structured logs. * The file picker and browser share one cached walk, with matching and ordering moved server-side. * **Document canvas.** In-chat canvas with media, PDF and sheet renderers, a Finder-like workspace view, and safe uploads. * **Composer mentions and paste.** Reference workspace files with `@`, and paste images directly. * **Affiliate tracking.** `Web().affiliate(key)` loads a referral tracker and reports conversions at checkout. * The canvas tool opens finished deliverables only, not transient files. * **Cost tracking.** Per-turn usage, per-chat aggregates, and `cycls cost` with `--since`, `--month` and `--by user|chat|model`. * **`cycls sql`.** SQL over the `logs` and `billing` tables, scoped to the deployments your key owns. * **Structured logging.** `cycls.log()` emits one record with user and chat attribution. Errors carry a reference id shown to the user, and every tool dispatch emits a `tool_call` record. * **Cheaper prompts.** Three cache breakpoints on the system prompt, the last tool and the last user turn, which cut cached input cost substantially on Anthropic. * Providers moved to one file each behind a neutral shape, with per-provider context window and max output tables. * Loop events became dict factories, which is why `cycls.to_ui` is now an identity function kept for compatibility. * Chat storage moved to a one-folder layout with glob scans. * **The primitives API.** `cycls.Image`, `cycls.Web` and `cycls.LLM` replaced keyword arguments on the decorators. Each is an immutable fluent builder. * **The CLI.** `cycls run`, `deploy`, `ls`, `rm`, `logs`, `init` and `version`. * **OpenAI adapter.** One Chat Completions path covering every OpenAI-compatible endpoint, alongside the native Anthropic path. * **Custom tool handlers.** `LLM().on(name, handler)` registers an async function whose return value reaches both the UI and the model. * **HTTP routes on agents.** `@my_agent.server.api_route(...)` adds routes to the same deployment. * **`cycls.Sandbox`.** Bubblewrap execution as its own primitive, with secure defaults. * **Live shares.** Stateless signed URLs replaced frozen share snapshots. * PDF page extraction for large files, and cost plus duration in the usage footer. * **The chat client.** A React interface with streaming, file uploads, a file manager, session list, rename and delete. * **Share links.** Conversations render as public pages with generated social preview images, including Arabic and right-to-left layouts. * **Arabic.** Full interface translation with a language toggle, plus localized sign-in. * **Voice input.** Dictation in the composer, with long-press to record. * **Plans and organizations.** Plan badges, an organization switcher, a pricing modal, and `?plan=` to open checkout directly. * **Branding from a CMS.** Logo, name and description pulled at startup. * LaTeX rendering, drag and drop uploads, auto-retry on connection errors, and an explore dropdown for related agents. * **The native agent harness.** A direct Anthropic loop with history, prompt caching and compaction, replacing the subprocess approach. * **Sandboxed bash.** Bubblewrap isolation with environment stripping, and editor paths restricted to the workspace. * **Sessions and files API.** List, load, rename and delete chats, plus full file endpoints. * **Attachments.** Upload limits, media sniffing, and recovery from API errors inside the loop. * **Organizations in the JWT.** Organization claims reach `context.user`. * Per-turn token usage and cost display, and incremental saves during a turn. * **Streaming UI components.** Yield dicts for thinking bubbles, code blocks, tables, callouts, status lines and images. * **The function runtime.** `@cycls.function` with local Docker execution, hot reload on save, and pre-built base images. * **Volumes and state.** Persistent key-value and filesystem storage for deployments. * **Automatic dotenv loading.** A `.env` bundled with the image is loaded at import, with no extra dependency. * **`context.last_message`** and the `Messages` wrapper with `.raw`. * Moved from Poetry to uv, added the test suite and CI. * Deployment moved to a two-step upload flow, which lifted the practical limit on build context size. * Error propagation from the container reached the interface instead of failing silently. * The chat interface was redesigned and gained image attachments. * Cycls Cloud went live for serverless deployment. * A single `key` parameter replaced the several per-service keys. * `copy_public` made it simple to ship static assets. * Cycls became open source at [github.com/Cycls/cycls](https://github.com/Cycls/cycls). * Decorator configuration for dependencies and authentication. * Every deployed agent served a streaming OpenAI-compatible endpoint. * Automatic web chat interface, with user and context management for multi-turn agents. # Core concepts Source: https://docs.cycls.com/get-started/concepts The mental model behind Cycls: three layers, four builders, and three verbs. Four ideas carry most of the SDK. Read them in order and the rest of the reference makes sense on first pass. ## 1. Three layers Each layer extends the one below it, so anything a Function can do an App can do, and anything an App can do an Agent can do. ```mermaid theme={null} flowchart TD A["@cycls.agent
chat product, managed LLM loop, web UI"] --> B["@cycls.app
blocking ASGI service, auth, per-user storage"] B --> C["@cycls.function
containerized Python, volumes, schedules"] ``` | Layer | Input | Output | Use it for | | ---------------------------- | ------------------ | ---------------------------- | ----------------------------------------------- | | [Function](/build/functions) | function arguments | return value | batch jobs, APIs, scheduled work, heavy compute | | [App](/build/apps) | HTTP requests | an ASGI app you return | dashboards, internal tools, custom front ends | | [Agent](/agents/overview) | `context` | a stream of events you yield | chat products | ## 2. Four builders Configuration lives in fluent, immutable builders. Every method returns a new object, so one base config can be branched safely. ```python theme={null} base = cycls.Image().pip("httpx") fast = base.pip("orjson") # a new Image slow = base.apt("ffmpeg") # another new Image, `base` is unchanged ``` `.pip()` `.apt()` `.copy()` `.run()` `.rebuild()` `.auth()` `.title()` `.brand()` `.analytics()` `.workspaces()` `.model()` `.system()` `.tools()` `.allowed_tools()` `.price()` `.read_only()` `.sub_path()` Decorators accept exactly the builders they need, never loose keyword soup. ```python theme={null} @cycls.function(image=..., volumes=..., schedule=..., cpu=..., memory=...) @cycls.app(image=..., volumes=..., auth=...) @cycls.agent(image=..., web=..., volumes=...) ``` ## 3. Three verbs The same three words work in Python and on the command line. | Verb | Where the code runs | Which code runs | | -------- | ------------------- | ------------------------------ | | `run` | your Docker | the code you are holding | | `remote` | Cycls Cloud | the code you are holding | | `deploy` | Cycls Cloud | the code frozen at deploy time | ```python theme={null} simulate.run(1000) # local Docker simulate.remote(1000) # cloud, current code simulate.deploy() # cloud, frozen and named ``` ```bash theme={null} cycls run file.py # local Docker, rerun on save cycls run file.py --remote # cloud, rerun on save cycls deploy file.py # freeze and publish ``` `f.remote()` is the development loop. `cycls.remote("name")` calls what was deployed, from any machine that has your API key. Read [Local and remote builds](/ship/builds) for what happens in each case. ## 4. Code travels as a pickle Your function is serialized with cloudpickle, including closures and captured variables, then executed inside a container built from your `Image`. Two rules follow, and both are enforced for you: * The container's Python matches your host's major and minor version. * The container's cloudpickle matches your host's exact version. A function defined in the file you deploy travels by value. A function imported from another module travels by reference, so the container will try to import that module. If you split code across files, bundle them with `cycls.Image().copy("helpers.py")` so the import resolves. ## State lives on volumes Containers are stateless. Anything that must survive a redeploy goes on a [volume](/build/volumes), which is named storage attached at a mount path. ```python theme={null} data = cycls.Volume("training-data") @cycls.function(volumes={"/data": data}) def crunch(day): import pandas as pd return pd.read_parquet(f"/data/events-{day}.parquet").sum() ``` Agents keep chats, files and credentials under `/workspace`, so the decorator requires a volume there: ```python theme={null} @cycls.agent(volumes={"/workspace": cycls.Volume("my-agent")}) async def my_agent(context): ... ``` Deleting a deployment detaches its volumes and never deletes their data. ## Images are content addressed Cycls hashes the image declaration, including the contents of copied files, into a deterministic Docker tag. Identical inputs reuse the cached build everywhere. Change one package and only that layer rebuilds. ## Next steps The managed loop, the context object and the event stream. Run, remote, map, deploy and schedule. # Introduction Source: https://docs.cycls.com/get-started/introduction Cycls is the deep-stack AI SDK for Python. Runtime, interface, intelligence and state are composable primitives in one file, deployed with one command. Write the agent, the container it runs in, the interface it serves and the storage it keeps, in one Python file. Then ship it. ```python hello.py theme={null} import cycls llm = ( cycls.LLM() .model("anthropic/claude-sonnet-4-6") .system("You are a helpful assistant.") .allowed_tools(["Bash", "Editor", "WebSearch"]) ) @cycls.agent(volumes={"/workspace": cycls.Volume("hello-chats")}) async def hello(context): async for ev in llm.run(context=context): yield ev ``` ```bash theme={null} cycls deploy hello.py # https://hello.cycls.ai ``` That deploy produces a chat interface, sign-in, per-user file storage, chat history, share links, an OpenAI-compatible API endpoint and a sandboxed tool loop. There is no Dockerfile, no YAML and no separate infrastructure repository. A Cycls agent streaming a markdown table and a Python code block in the chat interface ## The four layers | Layer | Primitive | What you declare | | ------------ | -------------------------------- | ------------------------------------------------------------ | | Runtime | [`cycls.Image`](/build/images) | pip packages, system packages, bundled files, build commands | | Interface | [`cycls.Web`](/web/interface) | branding, auth, themes, analytics, workspaces | | Intelligence | [`cycls.LLM`](/agents/models) | model, system prompt, tools, budgets, reasoning | | State | [`cycls.Volume`](/build/volumes) | named persistent storage, attached by mount path | Each primitive is an immutable builder, so every method returns a new object and one base configuration can be branched without side effects. ## Three decorators Decorators accept exactly the primitives they need. Each one extends the layer below it, so anything a function can do, an app can do, and anything an app can do, an agent can do. ``` @cycls.agent chat product, managed LLM loop, web UI extends @cycls.app blocking ASGI service, auth, per-user storage extends @cycls.function containerized Python, volumes, schedules ``` Run Python in a container locally, in the cloud, or on a schedule. Freeze it as a named endpoint anyone with your key can call. Return a FastAPI application and get a URL, sign-in and per-user storage. A chat product with a managed model loop, tools, memory, files and a web interface. ## What an agent includes Streaming chat in light and dark, English and Arabic, a file canvas, voice input, share links and generated social previews. Tool calling, retries, context compaction, cost accounting, and background runs that survive a dropped connection. Sandboxed bash, file editing, web search, a real browser, a key-value store, a canvas, app building, and your own Python handlers. Per-user files and chats on a volume you own, team workspaces, trash and restore, and shareable conversations. Clerk out of the box or any OIDC provider. Organizations, roles, plans and feature flags arrive as `context.user`. OAuth and API-key grants a user makes once, with per-tool approvals, an audit trail, and remote MCP servers on any provider. ## Model support One builder covers Anthropic natively and every OpenAI-compatible endpoint through a base URL. ```python theme={null} cycls.LLM().model("anthropic/claude-sonnet-4-6") cycls.LLM().model("openai/gpt-5.4") cycls.LLM().model("groq/llama-3.3-70b").base_url("https://api.groq.com/openai/v1") cycls.LLM().model("local/qwen3").base_url("http://localhost:8000/v1") ``` Reasoning control, tool calls and streaming are unified across providers. See [Models](/agents/models). ## Start here Install, run locally, deploy. About five minutes. Layers, builders, and the difference between run, remote and deploy. A guided build with tools, branding and a quota check. Every command, flag and default. # Quickstart Source: https://docs.cycls.com/get-started/quickstart Install Cycls, run an agent on your machine, then deploy it to a public URL. **Before you start** * Python 3.10 or newer * [Docker](https://www.docker.com/) running, for local runs and builds * A [Cycls API key](https://cloud.cycls.com) for deploying * A model key, for example `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`