[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6QQ9PVT908VAJJHTXPWXT08":3,"skill-same:01M6QQ9PVT908VAJJHTXPWXT08":69},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":43,"linkOnly":44,"markdownContent":45,"markdownBody":46,"frontMatter":47,"refName":48,"commitSha":49,"skillDirectory":50,"forksCount":51,"repositoryHtmlUrl":52,"repositoryHomepageUrl":6,"ownerHtmlUrl":53,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":54,"install":55,"resources":68},"01M6QQ9PVT908VAJJHTXPWXT08","platform",null,"The Opsiforce platform contract every agent runs under — an App is `\u002Fworkspace\u002Fapp\u002Fstartup.sh` serving on `$APP_PORT`, and it goes live the moment `app\u002Fapp.meta.json` exists. Also covers what a pod restart wipes and what the workspace keeps, uploaded and generated files, `app\u002Fopsiforce.env.json` and Publish, the platform environment variables, the LLM gateway and the ban on local models, reaching External Services on their Service hosts and relaying their refusals, and the two read-only platform databases. Load it before building, serving, installing, or debugging anything.","SimaDevelopment\u002Fopsiforce","SimaDevelopment","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F153810655?v=4","01M6QQ054MKBDSS43AND3FNHDK","Opsima","01M6QQ058VFT6EY7HCBVN49G8Z",42,"AGPL-3.0","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Fagent-config\u002Fskills\u002Fplatform\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000037","律师与法务","PROFESSION","适用职业",[5,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42],"restart","hosts","goes","Variables","port","environment variables","app","live","local","debugging","files","databases","only","generated","read","Meta","Local Models","publish","gateway","2026-11-10 10:12:30",false,"---\nname: platform\ndescription: The Opsiforce platform contract every agent runs under — an App is `\u002Fworkspace\u002Fapp\u002Fstartup.sh` serving on `$APP_PORT`, and it goes live the moment `app\u002Fapp.meta.json` exists. Also covers what a pod restart wipes and what the workspace keeps, uploaded and generated files, `app\u002Fopsiforce.env.json` and Publish, the platform environment variables, the LLM gateway and the ban on local models, reaching External Services on their Service hosts and relaying their refusals, and the two read-only platform databases. Load it before building, serving, installing, or debugging anything.\n---\n\n# The Opsiforce platform\n\nYou run inside a per-project pod on the Opsiforce platform. Everything below is true of every project, whatever stack you build in.\n\n## You do the work, and you install what it needs\n\nThe person in the chat talks to you; you are the one with the shell. Do the work yourself, and never ask them to run a command, edit a file or install something.\n\nThe container is a sandbox and you are root in it. Install whatever a task needs without asking — `apt-get install -y`, `pip install` (with `--break-system-packages` when PEP 668 refuses), `npm install -g` — and never refuse a task because a tool is missing. §The pod is disposable, the workspace is durable says which installs survive a restart: once the App depends on one, it moves into `startup.sh`.\n\n## The App contract\n\nAn **App** is three things. Satisfy them and the platform routes to it, shows a preview pane, reports it live, and publishes it. Miss one and nothing appears.\n\n1. **`\u002Fworkspace\u002Fapp\u002Fstartup.sh`** — an executable shell script that starts your server in the foreground. The platform runs it, restarts it when it exits, and runs it again in the published environment. Never start a server any other way: a process you launch yourself is not supervised and dies with your shell. Anything else the App needs installed or running comes from this same script too — see the next section.\n2. **It serves HTTP on `$APP_PORT`.** Read the port from the environment — never hardcode it. Any HTTP answer on that port, at any status, counts as serving; the platform does not require a health route.\n3. **`\u002Fworkspace\u002Fapp\u002Fapp.meta.json`** — `{\"name\": \"…\", \"description\": \"…\"}` and nothing else. Writing this file is **go-live**: it makes the preview pane appear and the public link work. Write it as soon as the first feature works, not at the end.\n\n```bash\nchmod +x \u002Fworkspace\u002Fapp\u002Fstartup.sh\n```\n\nUntil `startup.sh` exists the pod idles quietly — there is no penalty for a workspace with no App yet.\n\nIf `app.meta.json` already exists, leave its `name` and `description` alone. Users edit them from the platform and their edits must survive your changes; rewrite them only when the user asks to rename the App.\n\nAfter creating `app.meta.json`, run `app-url` and share what it prints, once — never the `localhost` address, and never again on later edits. When the user asks for the App's link, run `app-url` again: the address can change after go-live.\n\nIf `app-url` isn't available or can't reach the platform, share `APP_PUBLIC_URL` instead.\n\n**One Project is one App.** A project holds exactly one App and one conversation. If the user wants a different App, they start a new project.\n\n## The pod is disposable, the workspace is durable\n\n`\u002Fworkspace` is the project's own volume and survives everything. The container around it is the image plus whatever this session installed, and it is thrown away whenever the pod is recreated: the project stops or idles out and is resumed, the agent image is updated, or the App is published.\n\nGone with the container, every time:\n\n- `apt-get install`, `pip install` into the system site-packages, `npm install -g`\n- files you wrote outside `\u002Fworkspace` — in `\u002Froot`, `\u002Ftmp`, `\u002Fopt`, `\u002Fusr\u002Flocal`. What the image itself ships there (Node, Yarn, Python, `guard`, its CLIs) is back on every boot; never reinstall or re-enable it from `startup.sh`\n- exported environment variables, shell aliases, cron entries — recurring work is a platform schedule, not a crontab\n- every process you started by hand\n\n`startup.sh` is the only thing that runs again: on every fresh container, again in the same container whenever it exits, and in every published Environment from a fresh checkout. It is therefore the complete, repeatable recipe for bringing the App up from nothing, and everything in it follows from that.\n\n- **Ensure, never assume.** Anything the App needs at runtime that is not a file under `\u002Fworkspace\u002Fapp` — a system package, a language runtime, a tool, a service — is installed from `startup.sh`, **idempotently**: check whether it is there, install only when it is not. Every line must succeed both on a bare container and on one where the script already ran.\n- **Prefer what lives in the workspace.** A toolchain the image lacks is downloaded once into `\u002Fworkspace\u002F.cache\u002F\u003Ctool>-\u003Cversion>`, which git ignores and every restart keeps; the App's own packages and environment live under `app\u002F`. Either is paid once; a system install is paid on every boot, and in production on every deploy. Anything installed or built under `app\u002F` is listed in `app\u002F.gitignore`, because Publish commits everything git does not ignore.\n- **Work with no App follows the same rules.** Without `startup.sh` nothing runs again after a restart, so a script that needs something the image lacks ensures it into `\u002Fworkspace` before it runs.\n- **State lives under `\u002Fworkspace\u002Fapp\u002Fdata\u002F`.** Anything a process writes and must keep — a database, uploads, caches worth keeping — goes there, so it survives a restart. `data\u002F` is ignored by git, so none of it is published.\n- **A first-time setup is finished or redone, never half-trusted.** Initialise into a temporary directory beside the target and rename it into place once setup succeeds, so a container killed mid-setup leaves nothing that looks initialised.\n- **A service that refuses to run as root** runs as an unprivileged user that owns its directory under `data\u002F`. A user created in the container is gone after a restart, so `startup.sh` creates it idempotently.\n- **Every long-running process runs under `guard`, in the foreground.** `guard \u003Cname> \u003Ccommand> &` supervises it: the platform restarts it whenever it exits, crashed or not, and its output and lifecycle land in `\u002Fworkspace\u002Fdata\u002Flogs\u002F\u003Cname>.log`, read with `process-logs \u003Cname>`. A command that daemonises itself and returns escapes supervision, stays invisible to the logs, and collides with itself when `startup.sh` runs again — run the process's foreground mode instead. The server on `$APP_PORT` is one of these processes, not a special case. A server that must be compiled builds inside its guarded command — `guard \u003Cserver> sh -c '\u003Cbuild> && exec \u003Cbinary>'` — so every restart runs current source; in development it runs under a watcher that rebuilds on save, since nothing restarts it by hand.\n- **Expect leftovers from a dead container.** The previous container was killed, not shut down, so a process that keeps a lock or pid file under `data\u002F` finds it still there. Make its start tolerate that. Decide whether a service is already running by asking the service itself — its own ready check or client — never by a process number read from a file: numbers are reused in a new container, so a stale one can point at an unrelated process.\n- **Survive a dependency restarting.** A service the server uses can restart underneath it, and `guard` brings it back in seconds. The server reconnects on its next request instead of holding on to a connection that died with the old process.\n- **Order is the dependency order.** Start what the server needs before the server, and end with `wait -n` so the script exits — and is run again — when a supervised process gives up.\n\nThe shape every `startup.sh` takes, whatever the stack:\n\n```bash\n#!\u002Fbin\u002Fbash\nset -e\ncd \"$(dirname \"$0\")\"\n\ncommand -v \u003Ctool> >\u002Fdev\u002Fnull || \u003Cinstall tool>\n[ -e data\u002F\u003Cservice> ] || \u003Cinitialise data\u002F\u003Cservice>>\n\nguard \u003Cservice> \u003Crun service in the foreground> &\nguard \u003Cserver> \u003Crun server on $APP_PORT in the foreground> &\nwait -n\n```\n\nInstalling or starting something in chat to try it is fine. The moment the App depends on it, it moves into `startup.sh`, or the App breaks on the next restart. An edit to `startup.sh` takes effect the next time it runs, not now, so nothing proves it but a restart. Before relying on an install or prepare line you added — anything above the `guard` lines — run that line on its own twice: the first run does the work, the second must succeed and change nothing. Never run a `guard` line or the whole script by hand in the running container, which starts a second copy of what is already running, and never stop a service the App is using to see whether it comes back; the restart is that test.\n\n**What Publish carries.** A published Environment is its own checkout of the workspace's git repository, with `OPSIFORCE_ENV=production` set. `startup.sh` branches on that variable: install, build, and serve the built App — no dev servers, no hot reload. The checkout holds the code and nothing git ignores: no `data\u002F`, no installed dependencies, no `opsiforce.env.json` (Publish writes production's own). Its first deploy therefore starts with no data at all, and later deploys keep what it has gathered since. So everything the App needs in order to start — its schema, reference data, a first record — is created by the App's own code when it boots, never by hand in development. Schema changes are forward-only: each one is a **new** migration, and a migration already applied is never edited.\n\n## Dependencies: latest stable, then fixed\n\nWhen you add a dependency — a library, a framework, a tool, in any language — take its latest stable release: never an alpha, beta, release candidate, canary or `next` build unless the user asks for one. From then on the lockfile fixes it for this Project: do not move a dependency to a new major version unless the user asks or a fix cannot be made without it. A skill that names a library tells you what to use, not which version; when it says which major version its specifics were written for and the current stable release is newer, read that release's migration notes for the parts the skill relies on before you follow it.\n\n## Node projects use Yarn\n\nEvery Node.js project you create or change — the App or a tool it needs — uses Yarn, the version the image activates through corepack: `yarn install`, `yarn add`, one `yarn.lock`, and `yarn install --immutable` in the production branch of `startup.sh`. Never npm, pnpm or bun for a project, and never two lockfiles. Plug'n'Play (`nodeLinker: pnp` in `.yarnrc.yml`) is the default; use `nodeLinker: node-modules` only when PnP cannot be made to work with the App's stack, and decide that once for the App rather than switching back and forth.\n\n## Files the user gives you, and files you give back\n\n- **Uploads** land in `\u002Fworkspace\u002Fuser_uploaded_files\u002F`, with their original layout preserved — a single file at the root of that folder, an uploaded folder as its own subtree. Look there first whenever the user mentions something they uploaded.\n- **Anything you produce for the user** — CSV, spreadsheet, PDF, export, archive — goes under `\u002Fworkspace` and is shared as a plain absolute-path link:\n\n  `[sales-report.csv](\u002Fworkspace\u002Fsales-report.csv)`\n\n  The platform turns any `\u002Fworkspace\u002F...` link into a browser download. Use the bare path: **never** prefix it with `sandbox:` (the link is stripped and the user gets nothing), and no `file:` or `localhost` URLs, ad-hoc HTTP servers, or pasted file contents.\n- **Documents you generate belong in `\u002Fworkspace\u002Fgenerated_files\u002F`** — `mkdir -p` it on first use and link from there; the platform surfaces that folder as the project's **Generated files**.\n\n## App config and secrets — `app\u002Fopsiforce.env.json`\n\nAll of the App's own configuration and secrets (API keys, tokens, base URLs, flags) live as string keys in `app\u002Fopsiforce.env.json`, a flat JSON object. The App reads them **from that file, not from `process.env`**. It is set **per environment** at Publish, so development and production hold different values; it is never committed, and the backend re-reads it on its next restart.\n\nIt is internal plumbing — confirm the capability in plain language (\"the app now pulls live weather\") rather than naming the file or its keys. The publish dialog lists the keys for the user automatically.\n\nThe platform's own values below are real environment variables: read those from `process.env`.\n\n## Platform environment variables\n\n| Variable | What it is |\n|---|---|\n| `APP_PORT` | The port your App must serve on, and the only port the platform routes to |\n| `APP_PUBLIC_URL` | An address that serves this App, possibly an older form; use `app-url` for the link you share |\n| `OPSIFORCE_ENV` | `production` in a published environment; unset or otherwise in development |\n| `APP_LLM_BASE_URL` | OpenAI-compatible base URL for the LLM gateway |\n| `APP_LLM_API_KEY` | Key for that gateway |\n| `SERVICE_GATEWAY_URL` | Base URL of the platform's service gateway |\n| `SERVICE_GATEWAY_API_KEY` | The pod's key for the service gateway and for External Services |\n| `EXTERNAL_SERVICES_HOST` | The domain every External Service's host sits under: `\u003Cslug>.$EXTERNAL_SERVICES_HOST` (§External Services) |\n\n## AI runs on the gateway — never locally\n\n`APP_LLM_BASE_URL` and `APP_LLM_API_KEY` are an **OpenAI-compatible** endpoint serving GPT and Claude models. Point the `openai` SDK or a plain `curl` at it, from app code or straight from your shell, for text, structured output, streaming, vision, image generation, transcription and speech. The Organization chooses which providers serve each model family, and two rules keep every call on that choice: name models by their **bare id** (`gpt-6.1-sol`, `claude-sonnet-5-5`), since a `provider\u002F` prefix pins a single provider; and make every text call a **streamed Responses call** (`\u002Fresponses` with `\"stream\": true`), OpenAI's recommended API, which every provider serves.\n\n```bash\ncurl -sN \"$APP_LLM_BASE_URL\u002Fresponses\" \\\n  -H \"Authorization: Bearer $APP_LLM_API_KEY\" \\\n  -H 'Content-Type: application\u002Fjson' \\\n  -d '{\"model\":\"gpt-6.1-sol\",\"input\":\"hello\",\"stream\":true,\"store\":false}'\n```\n\nUse `APP_LLM_API_KEY` \u002F `APP_LLM_BASE_URL` for every gateway call. `OPENAI_API_KEY` and `OPENAI_BASE_URL` are your own chat credentials — never route an App through them.\n\n**Never install or run a local model.** No local speech-to-text (`openai-whisper`, `faster-whisper`, `vosk`), no local LLMs, no local image models, and no browser speech APIs (`SpeechRecognition`, `webkitSpeechRecognition`, `speechSynthesis`). They are slow on the container's CPU, lower quality, and bypass usage tracking. Fetching and preparing media with CLI tools (`ffmpeg`, `yt-dlp`) is fine — running the model itself locally is not.\n\n## Web search\n\n`websearch` is available to you directly. Use it freely, and use it **before** writing code whenever the user names something real — a brand, a person, a place, an API, a price, a \"top N\" list. Seed the App with facts you looked up, never with plausible-sounding inventions; when a search turns up nothing useful, ask the user one short question instead.\n\n> ❌ User names a real entity → you seed the DB with invented names, prices, or stats that look plausible\n> ✅ User names a real entity → you search first, then seed with real, verifiable facts\n\n## Platform observability — process logs and the request log\n\nThe platform records what every process prints and every request the App serves. Use both to investigate bugs, failed requests and crashes **before guessing**, and to confirm the App booted after a round of edits. Both are the platform's and read-only to you, and both keep only the last few days.\n\n### Process logs — `process-logs`, files under `\u002Fworkspace\u002Fdata\u002Flogs\u002F`\n\nEvery supervised process writes its stdout\u002Fstderr and its lifecycle events (`started`, `crashed`, `stopped`, `signal`, `gave_up`, with `exit_code`, `uptime_seconds` and `restart_count`) to `\u002Fworkspace\u002Fdata\u002Flogs\u002F\u003Cprocess>.log`, rotated as it grows. Read them with `process-logs`, which merges the rotated files and every process by time:\n\n```bash\nprocess-logs --list                # every process, its last write and size\nprocess-logs -n 100 \u003Cprocess>      # the last 100 lines of one process\nprocess-logs --follow \u003Cprocess>    # stream while you reproduce something\n```\n\n`--since`, `--events` and `--grep` narrow any of these; `process-logs --help` has the rest. Process names come from whatever `startup.sh` supervises, plus the platform's own: `webapp` is the supervisor's own meta-lines, `opencode` is you, `vscode` is the editor, `dbviewer` the database viewer.\n\n### The request log — `\u002Fworkspace\u002Fdata\u002Fdatabase.db`\n\nA SQLite file holding one table, `app_requests`: every HTTP request to the App. Always open it with `-readonly`.\n\n| Column | |\n|---|---|\n| `method`, `url`, `status`, `duration_ms`, `size`, `domain`, `created_at` | what was asked and how it went |\n| `request_body`, `response_body`, `request_headers`, `response_headers` | the payloads, when the Project's Request Logging level records them |\n\n### Verify the App booted — run after every round of edits\n\nBoth of these should come back empty (or show only healthy `started` events) before you open a browser or tell the user a feature is done:\n\n```bash\n# 1. Crashes in the last 2 minutes\nprocess-logs --events --since 2m\n\n# 2. Error lines in the last 2 minutes\nprocess-logs --since 2m --grep 'error|fail|cannot find|unexpected'\n```\n\nA recent `crashed` event with a climbing `restart_count` and small `uptime_seconds` is a crash loop — a hard error, fix it before continuing. `started` with nothing else recent is healthy.\n\n### Then walk it, and keep what you saw\n\nFix what those commands show before opening a browser: a blank page is almost always a crashed server, which the logs catch and guessing at the UI does not. When you carry `agent-browser`, walk the flow you changed in it before saying it is done — open the page, run the flow, check the result — and use it to see what the user sees when they report something broken. Save every screenshot to `\u002Fworkspace\u002F.cache\u002Fchecks\u002F`, which survives a restart and is never published.\n\n### Investigate failed requests and crashes\n\n```bash\n# Recent requests\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, duration_ms, created_at FROM app_requests ORDER BY id DESC LIMIT 50\"\n\n# 4xx\u002F5xx in the last hour, with the response body\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, substr(response_body,1,200) AS body, created_at\n   FROM app_requests WHERE status >= 400 AND created_at > datetime('now','-1 hour') ORDER BY id DESC\"\n\n# Slowest requests\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, duration_ms, created_at FROM app_requests ORDER BY duration_ms DESC LIMIT 20\"\n\n# What one process printed around a failure\nprocess-logs --since 15m \u003Cname>\n```\n\n`created_at` is UTC, as are the log timestamps, so a failed request and the process lines around it line up. Date filters like `datetime('now','-N hours')` work, or compare `created_at` to an ISO string. Drop `-header -column` when piping.\n\n## External Services — `https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST`\n\nAn External Service connects the project to an outside system: incoming email, WhatsApp, or a system the Organization added itself. Each one this project has arrives as its own `service-\u003Cslug>` skill: what the service is for, which outside API it passes through and where that API's docs are, how calling it here differs, how to set up your tools for it, and the `data` of each Event it delivers. This section is what every service shares. Services this Organization offers but this project has not connected are listed under External services in the chat bar; when a task needs one, tell the person to connect it there.\n\n### The Service host and your key\n\nEvery service answers on its own host, `https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST`, and speaks its outside system's own API there. Point that system's own client — its CLI, its SDK, git, curl — at the Service host in place of the system's address, and use the system's own paths and docs. The service adds the outside system's credentials itself.\n\nYour credential is `$SERVICE_GATEWAY_API_KEY`, sent in a header and never in a URL:\n\n```bash\ncurl -s \"https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST\u002F.well-known\u002Fopsiforce\" \\\n  -H \"Authorization: Bearer $SERVICE_GATEWAY_API_KEY\"\n```\n\nAny header carries it, Basic auth included (the key as username or password), so a tool that only offers its own auth header works too. The Service skill says how to set up each of its tools to send the key on the first request without stopping at a password prompt. A URL lands in shell history, git remotes and logs, so give a tool the key through its header setting even where it would accept `https:\u002F\u002Fuser:key@…`.\n\nApp code calls the same host with the same key, read from `process.env` on the App's server and kept out of the browser, so what you try from the shell behaves the same at runtime.\n\nThe host holds a request for up to a minute while a sleeping service wakes, so a slow first answer is normal.\n\n### Read `\u002F.well-known\u002Fopsiforce` before you plan\n\n`GET https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST\u002F.well-known\u002Fopsiforce` is the service's live answer for this ProjectEnvironment. Read it before you plan work with a service, and again after the user changes a setting:\n\n- `upstreams` — each outside API the service passes through: `api` (name and version), its real `url`, and the `mount`, the path prefix on the Service host that stands for that `url`.\n- `routes` — what the service serves, in order, first match wins: the pattern, `action` (`proxy` passes the request to an upstream, `handle` is answered by the service itself as its skill describes, `refuse` is blocked), a `summary`, and `allowedBy`, the settings that govern it. A request no route matches is refused.\n- `configuration` — this Project's settings by field, with labels and secrets masked, and `settingsLink`, the page where they change.\n\nA summary says when a route filters an answer or changes a request: read it as a difference from the outside system's docs. The settings and `allowedBy` tell you which requests a task takes; whether one is allowed is the service's to decide, from the request itself, so the answer holds no verdicts.\n\n`\u002F.well-known\u002Fopsiforce` answers for the environment you run in. In a Development chat that is Development's own configuration, which Publish carries to the published environment: Development changes go live on Publish, so never tell the user what Production can do from what you see here.\n\n### Make the request; the service decides\n\nOnly the service decides what this Project may do. When the user asks for something, make the request it takes and relay the answer, also when the settings suggest a refusal: the service's refusal carries the reason, the setting and its link, and its Call log shows the people who configure it what was asked for.\n\nTo act on something the user names without an id, find it through the service the way its outside system's API finds things (a search, a list) and use what comes back. An empty or filtered answer is the service's answer: tell the user the service returned nothing by that name for this Project and give the `settingsLink`, leaving the reason to the service. An id or a link the user gives you goes into the request as it is.\n\nTell the user something is refused, unavailable or not found only from a service answer to a request you made for it.\n\n### Relay a refusal\n\nThe service refuses in your client's own format, so your tool prints it: a CLI's error, git's `remote:` lines, a JSON body. The message says what was refused and why, names the setting and links to it. Every answer the service makes itself carries an `Opsiforce-Outcome` header, plus `Opsiforce-Setting: \u003Cfield>` when a setting governs it:\n\n- `not allowed` — the service's policy forbids the request: this Project's settings when `Opsiforce-Setting` names one, otherwise something only the person the message names can change, such as an Organization admin on the service's page. Stop and relay it. A *not allowed* is final: never retry it, and never reach the same result another way.\n- `not connected` — this Project has no connection to the service. Relay it: the user connects the service under External services in the chat bar.\n- `incomplete` — a required setting is empty. Relay it, naming the setting.\n- `not supported` — the service does not serve this request. Look for a route that does in `\u002F.well-known\u002Fopsiforce`, or tell the user this service cannot do it.\n- `failed` — the service or the outside system failed. Retry once when the request is safe to repeat, then report it.\n\nTo relay, tell the user in one or two plain sentences what you tried, why it was refused and which setting decides it, give the link from the message, and add whom to ask: the person the message names, or, when they can't change it there, someone who can configure this Project's External Services. Leave the status, headers and JSON out.\n\nThe host answers some requests before any service sees them: `401` when no valid key was in a header or this Project is disabled; `404` when this Organization has no such service, or \"\u003Cservice> is turned off in this Organization\", so say it is not available here instead of building around it; `502` or `504` when the service failed to start or did not start in time, so retry once a little later, then report it.\n\nIn App code a refusal reaches the App's end users, who cannot open the settings link: the App shows its own \"not available\" message and logs the refusal's message for whoever maintains it. That includes the host's `404`, which has no `Opsiforce-Outcome`: trying again will not help, so only a `failed`, `502` or `504` invites the user to retry.\n\n## Inbound Events — `\u002Fworkspace\u002Fdata\u002Fexternal-services.db`\n\nA second read-only SQLite file holding Events that External Services delivered to this environment, as each service's own settings for this Project choose (an email, a WhatsApp message, a card moved in another tool). The platform backend is the only writer; never create tables, write, or run migrations against it. Its tables are created when the first Event arrives, so `no such table: events` means \"nothing has arrived yet\", not a broken setup. Every service shares the tables, so always filter by `service` (and `type`).\n\n- `events`: `id`, `service`, `type`, `key` (unique per service and type: a resent Event is not stored twice), `occurred_at`, `received_at`, `data` (JSON, read with `json_extract`; its shape is in the service's `service-\u003Cslug>` skill), `app_delivered_at`.\n- `event_files`: `event_id`, `name`, `mime_type`, `size_bytes`, `path` — the bytes are a file at `path`, read it with `fs`.\n\nThe platform rings the App for every stored batch: `POST \u002Fapi\u002Fexternal-services\u002F\u003Cslug>` with `{ \"service\": \"\u003Cslug>\", \"eventIds\": [12, 13] }` and no auth header, waking the App first. The template's catch-all stub answers 501; add a concrete route for the service above it (`DoorbellNotification` is in `doorbell-notification.ts`):\n\n```typescript\n@Post(\"my-service\")\nasync myService(@Body() notification: DoorbellNotification): Promise\u003C{ received: true }> {\n  await this.myServiceEvents.process(notification.eventIds)\n  return { received: true }\n}\n```\n\n- The body carries ids only; read the rows from SQLite, so the live path and the catch-up path run the same code.\n- A 2xx stamps `app_delivered_at`. A 5xx or a timeout (10 s) is retried after 30 s, 2 min, 10 min, 1 h and 6 h. A 404 or 501 means \"not wired\": the platform stops ringing for that service until the App starts again. Reloading the server under its watcher does not count, so when you wire a service that already has Events waiting, tell the user they arrive at the App's next start, or at once if they restart this environment.\n- Answer quickly and process idempotently: key the App's own records on the Event `id` (a `UNIQUE` column in `app.db`), because an id can arrive twice.\n- Catch up on boot and from a schedule: `SELECT id FROM events WHERE service = ? AND app_delivered_at IS NULL ORDER BY id`.\n\nAn App built before Events existed reads the older `messages` \u002F `attachments` tables (attachment bytes inline as `BLOB`) and gets `rowIds` next to `eventIds`; the platform keeps writing both for that environment. Leave such an App on `messages` unless the user asks for a change; new code reads `events`.\n\n## What else the platform can do\n\nThese capabilities exist and are delivered as skills that can be added to an agent. If one is not in your skill list, the capability is still real — say so, and the user can have it added.\n\n- **AI gateway recipes** — worked examples for text generation, structured output, streaming, multi-turn conversations, tool calling, vision, image generation, transcription and text-to-speech against `APP_LLM_BASE_URL`.\n- **External Services** — the outside systems this platform and this Organization connect: what may arrive into the App and what an agent or App may call. An Agent knows a service through its `service-\u003Cslug>` skill: one the Agent carries, or one this project gets once the service is configured here. When a task needs an outside system none of them reaches, say so and offer an in-app alternative instead of installing a client library and asking for its credentials.\n- **Schedules** — recurring background work, run on a cron schedule by the platform.\n- **Browser testing** — a headless browser in the pod for walking the real user flow and seeing what the user sees.\n- **Document generation** — Word, PowerPoint, Excel and PDF deliverables, with the libraries already installed in the container.\n","# The Opsiforce platform\n\nYou run inside a per-project pod on the Opsiforce platform. Everything below is true of every project, whatever stack you build in.\n\n## You do the work, and you install what it needs\n\nThe person in the chat talks to you; you are the one with the shell. Do the work yourself, and never ask them to run a command, edit a file or install something.\n\nThe container is a sandbox and you are root in it. Install whatever a task needs without asking — `apt-get install -y`, `pip install` (with `--break-system-packages` when PEP 668 refuses), `npm install -g` — and never refuse a task because a tool is missing. §The pod is disposable, the workspace is durable says which installs survive a restart: once the App depends on one, it moves into `startup.sh`.\n\n## The App contract\n\nAn **App** is three things. Satisfy them and the platform routes to it, shows a preview pane, reports it live, and publishes it. Miss one and nothing appears.\n\n1. **`\u002Fworkspace\u002Fapp\u002Fstartup.sh`** — an executable shell script that starts your server in the foreground. The platform runs it, restarts it when it exits, and runs it again in the published environment. Never start a server any other way: a process you launch yourself is not supervised and dies with your shell. Anything else the App needs installed or running comes from this same script too — see the next section.\n2. **It serves HTTP on `$APP_PORT`.** Read the port from the environment — never hardcode it. Any HTTP answer on that port, at any status, counts as serving; the platform does not require a health route.\n3. **`\u002Fworkspace\u002Fapp\u002Fapp.meta.json`** — `{\"name\": \"…\", \"description\": \"…\"}` and nothing else. Writing this file is **go-live**: it makes the preview pane appear and the public link work. Write it as soon as the first feature works, not at the end.\n\n```bash\nchmod +x \u002Fworkspace\u002Fapp\u002Fstartup.sh\n```\n\nUntil `startup.sh` exists the pod idles quietly — there is no penalty for a workspace with no App yet.\n\nIf `app.meta.json` already exists, leave its `name` and `description` alone. Users edit them from the platform and their edits must survive your changes; rewrite them only when the user asks to rename the App.\n\nAfter creating `app.meta.json`, run `app-url` and share what it prints, once — never the `localhost` address, and never again on later edits. When the user asks for the App's link, run `app-url` again: the address can change after go-live.\n\nIf `app-url` isn't available or can't reach the platform, share `APP_PUBLIC_URL` instead.\n\n**One Project is one App.** A project holds exactly one App and one conversation. If the user wants a different App, they start a new project.\n\n## The pod is disposable, the workspace is durable\n\n`\u002Fworkspace` is the project's own volume and survives everything. The container around it is the image plus whatever this session installed, and it is thrown away whenever the pod is recreated: the project stops or idles out and is resumed, the agent image is updated, or the App is published.\n\nGone with the container, every time:\n\n- `apt-get install`, `pip install` into the system site-packages, `npm install -g`\n- files you wrote outside `\u002Fworkspace` — in `\u002Froot`, `\u002Ftmp`, `\u002Fopt`, `\u002Fusr\u002Flocal`. What the image itself ships there (Node, Yarn, Python, `guard`, its CLIs) is back on every boot; never reinstall or re-enable it from `startup.sh`\n- exported environment variables, shell aliases, cron entries — recurring work is a platform schedule, not a crontab\n- every process you started by hand\n\n`startup.sh` is the only thing that runs again: on every fresh container, again in the same container whenever it exits, and in every published Environment from a fresh checkout. It is therefore the complete, repeatable recipe for bringing the App up from nothing, and everything in it follows from that.\n\n- **Ensure, never assume.** Anything the App needs at runtime that is not a file under `\u002Fworkspace\u002Fapp` — a system package, a language runtime, a tool, a service — is installed from `startup.sh`, **idempotently**: check whether it is there, install only when it is not. Every line must succeed both on a bare container and on one where the script already ran.\n- **Prefer what lives in the workspace.** A toolchain the image lacks is downloaded once into `\u002Fworkspace\u002F.cache\u002F\u003Ctool>-\u003Cversion>`, which git ignores and every restart keeps; the App's own packages and environment live under `app\u002F`. Either is paid once; a system install is paid on every boot, and in production on every deploy. Anything installed or built under `app\u002F` is listed in `app\u002F.gitignore`, because Publish commits everything git does not ignore.\n- **Work with no App follows the same rules.** Without `startup.sh` nothing runs again after a restart, so a script that needs something the image lacks ensures it into `\u002Fworkspace` before it runs.\n- **State lives under `\u002Fworkspace\u002Fapp\u002Fdata\u002F`.** Anything a process writes and must keep — a database, uploads, caches worth keeping — goes there, so it survives a restart. `data\u002F` is ignored by git, so none of it is published.\n- **A first-time setup is finished or redone, never half-trusted.** Initialise into a temporary directory beside the target and rename it into place once setup succeeds, so a container killed mid-setup leaves nothing that looks initialised.\n- **A service that refuses to run as root** runs as an unprivileged user that owns its directory under `data\u002F`. A user created in the container is gone after a restart, so `startup.sh` creates it idempotently.\n- **Every long-running process runs under `guard`, in the foreground.** `guard \u003Cname> \u003Ccommand> &` supervises it: the platform restarts it whenever it exits, crashed or not, and its output and lifecycle land in `\u002Fworkspace\u002Fdata\u002Flogs\u002F\u003Cname>.log`, read with `process-logs \u003Cname>`. A command that daemonises itself and returns escapes supervision, stays invisible to the logs, and collides with itself when `startup.sh` runs again — run the process's foreground mode instead. The server on `$APP_PORT` is one of these processes, not a special case. A server that must be compiled builds inside its guarded command — `guard \u003Cserver> sh -c '\u003Cbuild> && exec \u003Cbinary>'` — so every restart runs current source; in development it runs under a watcher that rebuilds on save, since nothing restarts it by hand.\n- **Expect leftovers from a dead container.** The previous container was killed, not shut down, so a process that keeps a lock or pid file under `data\u002F` finds it still there. Make its start tolerate that. Decide whether a service is already running by asking the service itself — its own ready check or client — never by a process number read from a file: numbers are reused in a new container, so a stale one can point at an unrelated process.\n- **Survive a dependency restarting.** A service the server uses can restart underneath it, and `guard` brings it back in seconds. The server reconnects on its next request instead of holding on to a connection that died with the old process.\n- **Order is the dependency order.** Start what the server needs before the server, and end with `wait -n` so the script exits — and is run again — when a supervised process gives up.\n\nThe shape every `startup.sh` takes, whatever the stack:\n\n```bash\n#!\u002Fbin\u002Fbash\nset -e\ncd \"$(dirname \"$0\")\"\n\ncommand -v \u003Ctool> >\u002Fdev\u002Fnull || \u003Cinstall tool>\n[ -e data\u002F\u003Cservice> ] || \u003Cinitialise data\u002F\u003Cservice>>\n\nguard \u003Cservice> \u003Crun service in the foreground> &\nguard \u003Cserver> \u003Crun server on $APP_PORT in the foreground> &\nwait -n\n```\n\nInstalling or starting something in chat to try it is fine. The moment the App depends on it, it moves into `startup.sh`, or the App breaks on the next restart. An edit to `startup.sh` takes effect the next time it runs, not now, so nothing proves it but a restart. Before relying on an install or prepare line you added — anything above the `guard` lines — run that line on its own twice: the first run does the work, the second must succeed and change nothing. Never run a `guard` line or the whole script by hand in the running container, which starts a second copy of what is already running, and never stop a service the App is using to see whether it comes back; the restart is that test.\n\n**What Publish carries.** A published Environment is its own checkout of the workspace's git repository, with `OPSIFORCE_ENV=production` set. `startup.sh` branches on that variable: install, build, and serve the built App — no dev servers, no hot reload. The checkout holds the code and nothing git ignores: no `data\u002F`, no installed dependencies, no `opsiforce.env.json` (Publish writes production's own). Its first deploy therefore starts with no data at all, and later deploys keep what it has gathered since. So everything the App needs in order to start — its schema, reference data, a first record — is created by the App's own code when it boots, never by hand in development. Schema changes are forward-only: each one is a **new** migration, and a migration already applied is never edited.\n\n## Dependencies: latest stable, then fixed\n\nWhen you add a dependency — a library, a framework, a tool, in any language — take its latest stable release: never an alpha, beta, release candidate, canary or `next` build unless the user asks for one. From then on the lockfile fixes it for this Project: do not move a dependency to a new major version unless the user asks or a fix cannot be made without it. A skill that names a library tells you what to use, not which version; when it says which major version its specifics were written for and the current stable release is newer, read that release's migration notes for the parts the skill relies on before you follow it.\n\n## Node projects use Yarn\n\nEvery Node.js project you create or change — the App or a tool it needs — uses Yarn, the version the image activates through corepack: `yarn install`, `yarn add`, one `yarn.lock`, and `yarn install --immutable` in the production branch of `startup.sh`. Never npm, pnpm or bun for a project, and never two lockfiles. Plug'n'Play (`nodeLinker: pnp` in `.yarnrc.yml`) is the default; use `nodeLinker: node-modules` only when PnP cannot be made to work with the App's stack, and decide that once for the App rather than switching back and forth.\n\n## Files the user gives you, and files you give back\n\n- **Uploads** land in `\u002Fworkspace\u002Fuser_uploaded_files\u002F`, with their original layout preserved — a single file at the root of that folder, an uploaded folder as its own subtree. Look there first whenever the user mentions something they uploaded.\n- **Anything you produce for the user** — CSV, spreadsheet, PDF, export, archive — goes under `\u002Fworkspace` and is shared as a plain absolute-path link:\n\n  `[sales-report.csv](\u002Fworkspace\u002Fsales-report.csv)`\n\n  The platform turns any `\u002Fworkspace\u002F...` link into a browser download. Use the bare path: **never** prefix it with `sandbox:` (the link is stripped and the user gets nothing), and no `file:` or `localhost` URLs, ad-hoc HTTP servers, or pasted file contents.\n- **Documents you generate belong in `\u002Fworkspace\u002Fgenerated_files\u002F`** — `mkdir -p` it on first use and link from there; the platform surfaces that folder as the project's **Generated files**.\n\n## App config and secrets — `app\u002Fopsiforce.env.json`\n\nAll of the App's own configuration and secrets (API keys, tokens, base URLs, flags) live as string keys in `app\u002Fopsiforce.env.json`, a flat JSON object. The App reads them **from that file, not from `process.env`**. It is set **per environment** at Publish, so development and production hold different values; it is never committed, and the backend re-reads it on its next restart.\n\nIt is internal plumbing — confirm the capability in plain language (\"the app now pulls live weather\") rather than naming the file or its keys. The publish dialog lists the keys for the user automatically.\n\nThe platform's own values below are real environment variables: read those from `process.env`.\n\n## Platform environment variables\n\n| Variable | What it is |\n|---|---|\n| `APP_PORT` | The port your App must serve on, and the only port the platform routes to |\n| `APP_PUBLIC_URL` | An address that serves this App, possibly an older form; use `app-url` for the link you share |\n| `OPSIFORCE_ENV` | `production` in a published environment; unset or otherwise in development |\n| `APP_LLM_BASE_URL` | OpenAI-compatible base URL for the LLM gateway |\n| `APP_LLM_API_KEY` | Key for that gateway |\n| `SERVICE_GATEWAY_URL` | Base URL of the platform's service gateway |\n| `SERVICE_GATEWAY_API_KEY` | The pod's key for the service gateway and for External Services |\n| `EXTERNAL_SERVICES_HOST` | The domain every External Service's host sits under: `\u003Cslug>.$EXTERNAL_SERVICES_HOST` (§External Services) |\n\n## AI runs on the gateway — never locally\n\n`APP_LLM_BASE_URL` and `APP_LLM_API_KEY` are an **OpenAI-compatible** endpoint serving GPT and Claude models. Point the `openai` SDK or a plain `curl` at it, from app code or straight from your shell, for text, structured output, streaming, vision, image generation, transcription and speech. The Organization chooses which providers serve each model family, and two rules keep every call on that choice: name models by their **bare id** (`gpt-6.1-sol`, `claude-sonnet-5-5`), since a `provider\u002F` prefix pins a single provider; and make every text call a **streamed Responses call** (`\u002Fresponses` with `\"stream\": true`), OpenAI's recommended API, which every provider serves.\n\n```bash\ncurl -sN \"$APP_LLM_BASE_URL\u002Fresponses\" \\\n  -H \"Authorization: Bearer $APP_LLM_API_KEY\" \\\n  -H 'Content-Type: application\u002Fjson' \\\n  -d '{\"model\":\"gpt-6.1-sol\",\"input\":\"hello\",\"stream\":true,\"store\":false}'\n```\n\nUse `APP_LLM_API_KEY` \u002F `APP_LLM_BASE_URL` for every gateway call. `OPENAI_API_KEY` and `OPENAI_BASE_URL` are your own chat credentials — never route an App through them.\n\n**Never install or run a local model.** No local speech-to-text (`openai-whisper`, `faster-whisper`, `vosk`), no local LLMs, no local image models, and no browser speech APIs (`SpeechRecognition`, `webkitSpeechRecognition`, `speechSynthesis`). They are slow on the container's CPU, lower quality, and bypass usage tracking. Fetching and preparing media with CLI tools (`ffmpeg`, `yt-dlp`) is fine — running the model itself locally is not.\n\n## Web search\n\n`websearch` is available to you directly. Use it freely, and use it **before** writing code whenever the user names something real — a brand, a person, a place, an API, a price, a \"top N\" list. Seed the App with facts you looked up, never with plausible-sounding inventions; when a search turns up nothing useful, ask the user one short question instead.\n\n> ❌ User names a real entity → you seed the DB with invented names, prices, or stats that look plausible\n> ✅ User names a real entity → you search first, then seed with real, verifiable facts\n\n## Platform observability — process logs and the request log\n\nThe platform records what every process prints and every request the App serves. Use both to investigate bugs, failed requests and crashes **before guessing**, and to confirm the App booted after a round of edits. Both are the platform's and read-only to you, and both keep only the last few days.\n\n### Process logs — `process-logs`, files under `\u002Fworkspace\u002Fdata\u002Flogs\u002F`\n\nEvery supervised process writes its stdout\u002Fstderr and its lifecycle events (`started`, `crashed`, `stopped`, `signal`, `gave_up`, with `exit_code`, `uptime_seconds` and `restart_count`) to `\u002Fworkspace\u002Fdata\u002Flogs\u002F\u003Cprocess>.log`, rotated as it grows. Read them with `process-logs`, which merges the rotated files and every process by time:\n\n```bash\nprocess-logs --list                # every process, its last write and size\nprocess-logs -n 100 \u003Cprocess>      # the last 100 lines of one process\nprocess-logs --follow \u003Cprocess>    # stream while you reproduce something\n```\n\n`--since`, `--events` and `--grep` narrow any of these; `process-logs --help` has the rest. Process names come from whatever `startup.sh` supervises, plus the platform's own: `webapp` is the supervisor's own meta-lines, `opencode` is you, `vscode` is the editor, `dbviewer` the database viewer.\n\n### The request log — `\u002Fworkspace\u002Fdata\u002Fdatabase.db`\n\nA SQLite file holding one table, `app_requests`: every HTTP request to the App. Always open it with `-readonly`.\n\n| Column | |\n|---|---|\n| `method`, `url`, `status`, `duration_ms`, `size`, `domain`, `created_at` | what was asked and how it went |\n| `request_body`, `response_body`, `request_headers`, `response_headers` | the payloads, when the Project's Request Logging level records them |\n\n### Verify the App booted — run after every round of edits\n\nBoth of these should come back empty (or show only healthy `started` events) before you open a browser or tell the user a feature is done:\n\n```bash\n# 1. Crashes in the last 2 minutes\nprocess-logs --events --since 2m\n\n# 2. Error lines in the last 2 minutes\nprocess-logs --since 2m --grep 'error|fail|cannot find|unexpected'\n```\n\nA recent `crashed` event with a climbing `restart_count` and small `uptime_seconds` is a crash loop — a hard error, fix it before continuing. `started` with nothing else recent is healthy.\n\n### Then walk it, and keep what you saw\n\nFix what those commands show before opening a browser: a blank page is almost always a crashed server, which the logs catch and guessing at the UI does not. When you carry `agent-browser`, walk the flow you changed in it before saying it is done — open the page, run the flow, check the result — and use it to see what the user sees when they report something broken. Save every screenshot to `\u002Fworkspace\u002F.cache\u002Fchecks\u002F`, which survives a restart and is never published.\n\n### Investigate failed requests and crashes\n\n```bash\n# Recent requests\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, duration_ms, created_at FROM app_requests ORDER BY id DESC LIMIT 50\"\n\n# 4xx\u002F5xx in the last hour, with the response body\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, substr(response_body,1,200) AS body, created_at\n   FROM app_requests WHERE status >= 400 AND created_at > datetime('now','-1 hour') ORDER BY id DESC\"\n\n# Slowest requests\nsqlite3 -readonly -header -column \u002Fworkspace\u002Fdata\u002Fdatabase.db \\\n  \"SELECT method, url, status, duration_ms, created_at FROM app_requests ORDER BY duration_ms DESC LIMIT 20\"\n\n# What one process printed around a failure\nprocess-logs --since 15m \u003Cname>\n```\n\n`created_at` is UTC, as are the log timestamps, so a failed request and the process lines around it line up. Date filters like `datetime('now','-N hours')` work, or compare `created_at` to an ISO string. Drop `-header -column` when piping.\n\n## External Services — `https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST`\n\nAn External Service connects the project to an outside system: incoming email, WhatsApp, or a system the Organization added itself. Each one this project has arrives as its own `service-\u003Cslug>` skill: what the service is for, which outside API it passes through and where that API's docs are, how calling it here differs, how to set up your tools for it, and the `data` of each Event it delivers. This section is what every service shares. Services this Organization offers but this project has not connected are listed under External services in the chat bar; when a task needs one, tell the person to connect it there.\n\n### The Service host and your key\n\nEvery service answers on its own host, `https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST`, and speaks its outside system's own API there. Point that system's own client — its CLI, its SDK, git, curl — at the Service host in place of the system's address, and use the system's own paths and docs. The service adds the outside system's credentials itself.\n\nYour credential is `$SERVICE_GATEWAY_API_KEY`, sent in a header and never in a URL:\n\n```bash\ncurl -s \"https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST\u002F.well-known\u002Fopsiforce\" \\\n  -H \"Authorization: Bearer $SERVICE_GATEWAY_API_KEY\"\n```\n\nAny header carries it, Basic auth included (the key as username or password), so a tool that only offers its own auth header works too. The Service skill says how to set up each of its tools to send the key on the first request without stopping at a password prompt. A URL lands in shell history, git remotes and logs, so give a tool the key through its header setting even where it would accept `https:\u002F\u002Fuser:key@…`.\n\nApp code calls the same host with the same key, read from `process.env` on the App's server and kept out of the browser, so what you try from the shell behaves the same at runtime.\n\nThe host holds a request for up to a minute while a sleeping service wakes, so a slow first answer is normal.\n\n### Read `\u002F.well-known\u002Fopsiforce` before you plan\n\n`GET https:\u002F\u002F\u003Cslug>.$EXTERNAL_SERVICES_HOST\u002F.well-known\u002Fopsiforce` is the service's live answer for this ProjectEnvironment. Read it before you plan work with a service, and again after the user changes a setting:\n\n- `upstreams` — each outside API the service passes through: `api` (name and version), its real `url`, and the `mount`, the path prefix on the Service host that stands for that `url`.\n- `routes` — what the service serves, in order, first match wins: the pattern, `action` (`proxy` passes the request to an upstream, `handle` is answered by the service itself as its skill describes, `refuse` is blocked), a `summary`, and `allowedBy`, the settings that govern it. A request no route matches is refused.\n- `configuration` — this Project's settings by field, with labels and secrets masked, and `settingsLink`, the page where they change.\n\nA summary says when a route filters an answer or changes a request: read it as a difference from the outside system's docs. The settings and `allowedBy` tell you which requests a task takes; whether one is allowed is the service's to decide, from the request itself, so the answer holds no verdicts.\n\n`\u002F.well-known\u002Fopsiforce` answers for the environment you run in. In a Development chat that is Development's own configuration, which Publish carries to the published environment: Development changes go live on Publish, so never tell the user what Production can do from what you see here.\n\n### Make the request; the service decides\n\nOnly the service decides what this Project may do. When the user asks for something, make the request it takes and relay the answer, also when the settings suggest a refusal: the service's refusal carries the reason, the setting and its link, and its Call log shows the people who configure it what was asked for.\n\nTo act on something the user names without an id, find it through the service the way its outside system's API finds things (a search, a list) and use what comes back. An empty or filtered answer is the service's answer: tell the user the service returned nothing by that name for this Project and give the `settingsLink`, leaving the reason to the service. An id or a link the user gives you goes into the request as it is.\n\nTell the user something is refused, unavailable or not found only from a service answer to a request you made for it.\n\n### Relay a refusal\n\nThe service refuses in your client's own format, so your tool prints it: a CLI's error, git's `remote:` lines, a JSON body. The message says what was refused and why, names the setting and links to it. Every answer the service makes itself carries an `Opsiforce-Outcome` header, plus `Opsiforce-Setting: \u003Cfield>` when a setting governs it:\n\n- `not allowed` — the service's policy forbids the request: this Project's settings when `Opsiforce-Setting` names one, otherwise something only the person the message names can change, such as an Organization admin on the service's page. Stop and relay it. A *not allowed* is final: never retry it, and never reach the same result another way.\n- `not connected` — this Project has no connection to the service. Relay it: the user connects the service under External services in the chat bar.\n- `incomplete` — a required setting is empty. Relay it, naming the setting.\n- `not supported` — the service does not serve this request. Look for a route that does in `\u002F.well-known\u002Fopsiforce`, or tell the user this service cannot do it.\n- `failed` — the service or the outside system failed. Retry once when the request is safe to repeat, then report it.\n\nTo relay, tell the user in one or two plain sentences what you tried, why it was refused and which setting decides it, give the link from the message, and add whom to ask: the person the message names, or, when they can't change it there, someone who can configure this Project's External Services. Leave the status, headers and JSON out.\n\nThe host answers some requests before any service sees them: `401` when no valid key was in a header or this Project is disabled; `404` when this Organization has no such service, or \"\u003Cservice> is turned off in this Organization\", so say it is not available here instead of building around it; `502` or `504` when the service failed to start or did not start in time, so retry once a little later, then report it.\n\nIn App code a refusal reaches the App's end users, who cannot open the settings link: the App shows its own \"not available\" message and logs the refusal's message for whoever maintains it. That includes the host's `404`, which has no `Opsiforce-Outcome`: trying again will not help, so only a `failed`, `502` or `504` invites the user to retry.\n\n## Inbound Events — `\u002Fworkspace\u002Fdata\u002Fexternal-services.db`\n\nA second read-only SQLite file holding Events that External Services delivered to this environment, as each service's own settings for this Project choose (an email, a WhatsApp message, a card moved in another tool). The platform backend is the only writer; never create tables, write, or run migrations against it. Its tables are created when the first Event arrives, so `no such table: events` means \"nothing has arrived yet\", not a broken setup. Every service shares the tables, so always filter by `service` (and `type`).\n\n- `events`: `id`, `service`, `type`, `key` (unique per service and type: a resent Event is not stored twice), `occurred_at`, `received_at`, `data` (JSON, read with `json_extract`; its shape is in the service's `service-\u003Cslug>` skill), `app_delivered_at`.\n- `event_files`: `event_id`, `name`, `mime_type`, `size_bytes`, `path` — the bytes are a file at `path`, read it with `fs`.\n\nThe platform rings the App for every stored batch: `POST \u002Fapi\u002Fexternal-services\u002F\u003Cslug>` with `{ \"service\": \"\u003Cslug>\", \"eventIds\": [12, 13] }` and no auth header, waking the App first. The template's catch-all stub answers 501; add a concrete route for the service above it (`DoorbellNotification` is in `doorbell-notification.ts`):\n\n```typescript\n@Post(\"my-service\")\nasync myService(@Body() notification: DoorbellNotification): Promise\u003C{ received: true }> {\n  await this.myServiceEvents.process(notification.eventIds)\n  return { received: true }\n}\n```\n\n- The body carries ids only; read the rows from SQLite, so the live path and the catch-up path run the same code.\n- A 2xx stamps `app_delivered_at`. A 5xx or a timeout (10 s) is retried after 30 s, 2 min, 10 min, 1 h and 6 h. A 404 or 501 means \"not wired\": the platform stops ringing for that service until the App starts again. Reloading the server under its watcher does not count, so when you wire a service that already has Events waiting, tell the user they arrive at the App's next start, or at once if they restart this environment.\n- Answer quickly and process idempotently: key the App's own records on the Event `id` (a `UNIQUE` column in `app.db`), because an id can arrive twice.\n- Catch up on boot and from a schedule: `SELECT id FROM events WHERE service = ? AND app_delivered_at IS NULL ORDER BY id`.\n\nAn App built before Events existed reads the older `messages` \u002F `attachments` tables (attachment bytes inline as `BLOB`) and gets `rowIds` next to `eventIds`; the platform keeps writing both for that environment. Leave such an App on `messages` unless the user asks for a change; new code reads `events`.\n\n## What else the platform can do\n\nThese capabilities exist and are delivered as skills that can be added to an agent. If one is not in your skill list, the capability is still real — say so, and the user can have it added.\n\n- **AI gateway recipes** — worked examples for text generation, structured output, streaming, multi-turn conversations, tool calling, vision, image generation, transcription and text-to-speech against `APP_LLM_BASE_URL`.\n- **External Services** — the outside systems this platform and this Organization connect: what may arrive into the App and what an agent or App may call. An Agent knows a service through its `service-\u003Cslug>` skill: one the Agent carries, or one this project gets once the service is configured here. When a task needs an outside system none of them reaches, say so and offer an in-app alternative instead of installing a client library and asking for its credentials.\n- **Schedules** — recurring background work, run on a cron schedule by the platform.\n- **Browser testing** — a headless browser in the pod for walking the real user flow and seeing what the user sees.\n- **Document generation** — Word, PowerPoint, Excel and PDF deliverables, with the libraries already installed in the container.\n",{"name":5,"description":7},"main","c54d8e35f50ac35b3b2c7ba8d4fca2205a740cb9","agent-config\u002Fskills\u002Fplatform",9,"https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce","https:\u002F\u002Fgithub.com\u002FSimaDevelopment","2026-11-05 06:13:10",{"source":8,"skillName":5,"commands":56,"prompt":66,"zipAvailable":67},[57,60,63],{"tool":58,"command":59},"npx","npx skills add SimaDevelopment\u002Fopsiforce --skill platform",{"tool":61,"command":62},"bunx","bunx skills add SimaDevelopment\u002Fopsiforce --skill platform",{"tool":64,"command":65},"pnpm","pnpm dlx skills add SimaDevelopment\u002Fopsiforce --skill platform","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Fagent-config\u002Fskills\u002Fplatform\u002FSKILL.md",true,[],[70,106,126,147,157,182],{"skillNo":71,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":74,"categories":75,"keywords":87,"publishedAt":43},"01M6QQKHZFGFB6A111RW2A9WC0","figma-use","**MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perform a write action or a unique read action that requires JavaScript execution in the Figma file context — e.g. create\u002Fedit\u002Fdelete nodes, set up variables or tokens, build components and variants, modify auto-layout or fills, bind variables to properties, or inspect file structure programmatically.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-use\u002FSKILL.md",[76,81,84],{"categoryNo":77,"categoryName":78,"taxonomyType":79,"taxonomyTypeDesc":80},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",{"categoryNo":82,"categoryName":83,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000030","开发工程师",{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000032","设计师",[88,89,90,91,92,93,94,38,95,96,97,98,99,100,101,27,102,103,104,105],"figma use","figma","use","components","create","delete","tool","write","edit","context","auto","structure","tokens","bind","you","inspect","variants","invoke",{"skillNo":107,"name":108,"nameZh":6,"description":109,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":110,"categories":111,"keywords":117,"publishedAt":43},"01M6QQK1AMA90K6DFAVANYQ9X3","figma-use-slides","This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-use-slides\u002FSKILL.md",[112,115,116],{"categoryNo":113,"categoryName":114,"taxonomyType":79,"taxonomyTypeDesc":80},"01JBSEEDCAT000000000000003","演示文稿",{"categoryNo":77,"categoryName":78,"taxonomyType":79,"taxonomyTypeDesc":80},{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[88,89,118,119,90,120,94,97,121,122,123,124,125],"figma use slides","slides","MCP","false","which","can","for","figma mcp",{"skillNo":127,"name":128,"nameZh":6,"description":129,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":130,"categories":131,"keywords":134,"publishedAt":43},"01M6QQJTR9QYK297WQ1X792D63","figma-use-motion","Motion \u002F animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a node.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-use-motion\u002FSKILL.md",[132,133],{"categoryNo":77,"categoryName":78,"taxonomyType":79,"taxonomyTypeDesc":80},{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[88,89,135,90,136,120,137,94,138,139,97,140,141,142,121,143,144,145,146,124],"figma use motion","motion","Animation","node","editing","Nodes","task","keyframes","via","styles","manual","load",{"skillNo":148,"name":149,"nameZh":6,"description":150,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":151,"categories":152,"keywords":155,"publishedAt":43},"01M6QQJNZ067ZRXZ6A0QB9M6SB","figma-use-figjam","This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-use-figjam\u002FSKILL.md",[153,154],{"categoryNo":77,"categoryName":78,"taxonomyType":79,"taxonomyTypeDesc":80},{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[88,89,156,90,120,94,97,121,122,123,124,125],"figma use figjam",{"skillNo":158,"name":159,"nameZh":6,"description":160,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":161,"categories":162,"keywords":164,"publishedAt":43},"01M6QQJ7NEHXPE33VRVHYYM7YE","figma-swiftui","SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views \u002F screens \u002F tokens back into a Figma file (code → design). Triggers on phrases like 'implement this Figma design in SwiftUI', 'build this screen in Swift', 'push this SwiftUI view to Figma', 'mirror my Swift code in a Figma file', or whenever a Figma URL appears alongside `.swift` files \u002F an `.xcodeproj`. Routes to a direction-specific reference doc; loads alongside `figma-use` for the code → design path.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-swiftui\u002FSKILL.md",[163],{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[89,165,166,167,168,169,170,171,34,172,173,174,175,176,121,177,178,179,180,181],"figma swiftui","swiftui","design","swift","code","push","doc","translation","back","either","iphone","ipad","screen","direction","URL","my","path",{"skillNo":183,"name":184,"nameZh":6,"description":185,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":186,"categories":187,"keywords":189,"publishedAt":43},"01M6QQJ3WZ3M90BZQ5TD305PKN","figma-shaders","**MANDATORY prerequisite** — load this skill before calling `create_shader` or `update_shader`. Use when the user asks to create, author, change, fix, or iterate on a shader effect, shader fill, custom effect, custom fill, or procedural shader in Figma.","https:\u002F\u002Fgithub.com\u002FSimaDevelopment\u002Fopsiforce\u002Fblob\u002Fmain\u002Ffrontend\u002Fopencode\u002Fpackages\u002Fplugins\u002Ffigma\u002Fskills\u002Ffigma-shaders\u002FSKILL.md",[188],{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[190,89,191,192,92,193,194,195,196,90,197,198,121,199,146,200,201,202,203],"shaders","figma shaders","change","update","effect","user","fix","shader","calling","procedural","fill","iterate","custom","author"]