duct / backend
AGENTS.md8 starsChanged 7 days agoMITView on GitHub
Python reporting and synthesis backend for Duct. Per the MVP plan (duct-cloud, private), this backend is the actual product engine: The web app owns HTML rendering. The backend produces JSON payloads only — it does not render HTML. - AI synthesis: Every agent runs on V1 (LangChain 1.x / deepagents) — the only engine. Engine selection is still per request, defaulting from the generate_engine env var, because stored preferences and requests carry the string; resolve_engine folds any other value back…
- Reads credentials
- Installs packages
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository20 other files it gives its agents.
- AGENTS.mdAGENTS.md
- app/AGENTS.mdAGENTS.md
- desktop/AGENTS.mdAGENTS.md
- site/AGENTS.mdAGENTS.md
- .github/copilot-instructions.mdCopilot instructions
- .cursor/rules/landing-pages.mdcCursor rule
- site/llms.txtllms.txt
- add-blog-postSkill
- add-changelog-entrySkill
- add-connectorSkill
- component-previewSkill
- featureSkill
- launch-readiness-auditSkill
- mosaic-panelSkill
- new-pageSkill
- prioritizeSkill
- security-auditSkill
- session-auditSkill
- working-backwardsSkill
- duct-sprint-refreshSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
24 rules. About 0 are common, 12 are distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈12
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 9
read from client-owned destinations with read-only access
- Very specific: line 21
AI synthesis: Every agent runs on V1 (LangChain 1.x / deepagents) — the only engine.
- Very specific: line 178
V2 (Google ADK) was removed: Not on framework merit — ADK is actively developed and Google-backed — but because nothing dispatched its …
- Distinctive: line 446
Do not build a dashboard-first product here.
- Very specific: line 457
Every connect starts with a ?link= naming the signed-in user and ends with that user's page claiming the grant.
- Very specific: line 639
Sign-in is GitHub's shape: Start (signed in) parks the flow under the OAuth state.
- Very specific: line 800
Every tool call passes through recorder_tool_hooks.
- Very specific: line 1000
All other fields follow the typed models in service/google/schema.py
- Very specific: line 1002
Do not write HTML from the backend.
- Distinctive: line 1007
Keep typed schemas central and explicit.
- Distinctive: line 1010
Prefer extensible evidence models so future tools can enrich the same findings.
- Very specific: line 1020
Never
datetime.now()ordatetime.utcnow(): every persisted timestamp is UTC-aware, and naive ones compare and serialise inconsistently … - Very specific: line 1028
ONLY write path (it redacts secrets, dedupes, honours the pause switch, and closes the previous value of a state key), search() is the only …
- Distinctive: line 1349
Start with one customer, one tool, one brief/report end-to-end.
- Distinctive: line 1350
Add connectors and orchestration only after the output format is useful.
- Distinctive: line 1351
Add real-time anomaly detection after the scheduled brief flow works.
- Distinctive: line 1355
no custom auth in backend
- Distinctive: line 1356
no full Airbyte platform management
- Distinctive: line 1357
no heavyweight job system beyond the planned orchestration layer
- Distinctive: line 1358
no broad dashboard experience
- Distinctive: line 1359
no complex cross-tool logic before the single-source MVP is producing useful output
- Very specific: line 1374
If it is a credential (matches api_key|_secret|_token|_dsn|password|client_id|encryption_key|jwt_secret), the test requires it in …
- Very specific: line 1377
If it is read from os.environ directly rather than through Configs, add it to NOT_CONFIG_FIELDS in that test with the reason.
- Very specific: line 1392
Always create Alembic revisions with autogenerate — alembic revision --autogenerate -m "..." or python scripts/migrations.py revision ....
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Duct Backend — agent instructions
Python reporting and synthesis backend for Duct.
Product role
Per the MVP plan (duct-cloud, private), this backend is the actual product engine:
- read from client-owned destinations with read-only access
- normalize data into typed internal models
- compute signals and comparisons
- synthesize findings into structured output
- deliver via email and alerts
The web app owns HTML rendering. The backend produces JSON payloads only — it does not render HTML.
MVP architecture
Current stack
AI synthesis: Every agent runs on V1 (LangChain 1.x /
deepagents) — the only engine. Engine selection is still per request, defaulting from thegenerate_engineenv var, because stored preferences and requests carry the string;resolve_enginefolds any other value back tov1.The consolidation is finished. Per the engine consolidation review (duct-cloud, private), every agent moved to one harness — LangChain 1.x /
deepagents— because customers bring their own model (OpenAI / Gemini / Claude / xAI / OpenRouter, an Ollama server, or any OpenAI-compatible endpoint) and the Claude Agent SDK was Anthropic-only by design (upstream issue #410, closednot planned). Insights (agents/insights/v1/runner.py) and content (agents/content/v1/runner.py) aredeepagentssessions; audit's runner is on thecreate_agentrung (build_session_agent) driven by the same sharedDeepSession.V3 (Claude Agent SDK) was removed last, because it was the hardest: the project-scoped audit ran it unconditionally, and four capabilities existed nowhere else. Each was ported first — a chat loop and
run_resume(both DeepSession's, which audit now uses), the project artifact library (agents/core/artifact_tools.py), and the competitor-research pass (agents/audit/enrichment.py, nowcreate_agent+ web tools on any provider). Verified live on Gemini and OpenAI against the audit rubric before the delete.One capability genuinely went with it, deliberately: a Claude subscription (
sk-ant-oat…) authenticates only through the CLI, and the Messages API rejects it (Anthropic disabled third-party OAuth in Feb 2026). Claude needs an ANTHROPIC_API_KEY everywhere now.agents/content/persistence.pydetects the token prefix and says so once rather than failing a call per turn.How content and insights got there, recorded because the moves are the pattern any future port follows. Content V3 was removed once its port landed. What moved, and where it went: the SDK's
Agenttool becamedeepagentssub-agents dispatched throughtask(agents/content/subagents/, now framework-free dicts); the in-process MCP server became the LangChain binderbuild_content_tools_lcwith the tool bodies unchanged; the CLI'sWebSearch/WebFetchbecameagents/core/web_tools.py— both as Duct tools, on the rule image generation already set: a capability the running model may not have is a Duct tool, not a provider feature every model must support. The one exception is a built-in that survives a real tool-calling loop, and Anthropic's is the only one that does, so it is bound there (versioned per model — Opus 5.5 and Sonnet 5.5 takeweb_search_20260209, the rest the basic variant). Every other provider gets Duct's ownWebSearch, an ordinary function tool over an isolated grounded Gemini call (service/google/gemini/search.py), because Gemini refusesgoogle_searchalongside function declarations on 2.5 outright and on 3.x without atool_configflag that langchain-google-genai drops whenevertool_choiceis set.tests/test_web_search.pyholds that matrix, measured, aslivetests;AskUserQuestionbecame a checkpointedinterrupt(), so a question survives a redeploy; and the thread is keyed on the conversation, so a resume continues it rather than re-priming from the transcript (the DB re-prime remains for conversations recorded before the thread was durable).Images are a second provider inside a content run, and not a fixed one. The conversation runs on whatever key the user brought for chat; the pictures run on whatever image-capable key they brought — Gemini, OpenAI, xAI or OpenRouter, in that order of preference (
agents/models.IMAGE_PROVIDER_ORDER; the gateway is last because a first-party key for the same model is one hop fewer). The seam isservice/images/: one request shape, oneImageAPIError, one factory (image_client_for), four backends (service/google/gemini/client.py,service/openai/images.py,service/xai/images.py,service/openrouter/images.py).routes/content.pyresolves the image run once per session (agents/engines.resolve_image_run) and stashes provider + key on it; the tools spend that or decline, and the decline names every provider that would unblock the user. The agent's tool schema still defaults to the Gemini model id, soimage_model_forswaps in the resolved provider's default rather than refusing — the agent asked for an image, not a Google image. Adding a backend means a client module, anImageModelentry whose prefixprovider_ofrecognises, aDEFAULT_IMAGE_MODELSrow, and a branch in the factory;/providers/statusand/models/cataloguederive the settings page's Images row from those same tables, so nothing in the browser lists a model.The OpenRouter backend is a short list, not a catalogue. They front 52 image models;
ImageModelnames four, because it is a Pydantic enum in the content agent's tool schema and opening it to free-form slugs — the way the chat catalogue deliberately does — would let a run invent a model and fail on a slide. Three of the four reach vendors no first-party key here can (Seedream, Flux, Recraft, the last emitting SVG); the fourth is the cheap Gemini workhorse. Adding one is an enum line plus a_CAPSrow inservice/openrouter/images.py, and that row is not optional: the four models disagree aboutresolution, ratio lists and how many images one call may return, so every request is clamped to the table and the clamp is logged rather than sent hopefully. The ChatGPT-subscription route (agents/core/codex.py) is deliberately not an image backend: it can draw through Codex's hosted tool, but OpenAI's own docs scope subscription sign-in to Codex products and it is Plus-and-up, per-minute-quota'd, and unofficial — the wrong thing to put a customer's slide deck on.Video is the same seam, with one rule images do not need (issue #284).
service/videos/mirrorsservice/images/: one request shape, oneVideoAPIErrorcarrying a code (a filter refusal, a refused realistic face, a timeout, no credit — each asks the agent for something different), one factory, two backends — Veo on a Gemini key (service/google/gemini/veo.py) and Seedance through OpenRouter (service/openrouter/videos.py), resolved byagents/engines.resolve_video_runon the image rules and avideo_modelsaved pick.service/videos/caps.pyfits every request to what its model makes (lengths, ratios, 1080p only on an 8 s Veo clip) and says so in sentences the agent relays. The rule images do not need: a clip takes minutes and costs dollars, sogenerate_videoholds no DB connection while it waits, bills each clip as amedia-scopeTOKEN_USAGE(Veo fromVIDEO_PRICE_PER_SECOND, Seedance from the cost OpenRouter reports), and the content runner caps it at three a turn (RunLimits.per_tool_run_limits). A clip is acontent_assetsrow with avideo/*type;content_posts.videonames the take that publishes (agents/content/video.py), and publishing a video post sends that file alone.Two consequences, stated rather than discovered later. Content on Claude now needs an API key —
routes/content.pyrefuses the subscription credential with the same 402 the browser already handles. And the model only sees the images it generates on Anthropic: image blocks inside a tool result are accepted there and rejected by the OpenAI chat API, soVISION_PROVIDERSdecides whether the tools return pictures or URLs, and the system prompt says which. Where it does see them,SeenImagePruneMiddleware(agents/core/lc.py) swaps the base64 for a note after the model call that looked at it: the thread is durable and the Postgres saver writes the wholemessageschannel per superstep, so a picture left in state would be copied into every later checkpoint.Insights V3 was removed earlier for a different reason: nothing dispatched it. Both live routes (
routes/generate.py,routes/agents.py) driveAutonomousInsightsRunner, while the V3 runner still claimed parity with the olderGenerateInsightsAgentfetch/synthesize pair — an interface the routes had already left behind.GenerateInsightsAgentand its tool registry were removed too, for the third time for the same reason: no route dispatched them. With it wentagents/insights/tools.py(per-connectorStructuredToolfactories) andagents/insights/registry.py(goal_relevancescoring that ranked a set of 12 entities down to 8 — selection pressure that never existed). The autonomous runner reaches every entity throughFetchData(entity_id=…)against the catalog, so the catalog's dispatch key was renamedtool→fetch_fn: it names an internal function, and only looked like a tool reference while those tools existed.A ChatGPT plan is now a live OpenAI credential, wired where the note above said it should be:
agents/core/lc.resolve_chat_modelsends a subscription credential to_ChatOpenAICodex(agents/core/codex.py), so every runner gets it. The credential is the access token the desktop shell minted — it arrives inX-Provider-OpenAIbesideX-OpenAI-Account-Id,service/auth.pypacks the two into one string, and the shape (a JWT, notsk-…) is what routes it.ProviderKey.sourceissubscription; it is the user's own plan, never billed to Duct. The refresh token never reaches the backend,PUT /providers/openai/keyrefuses to store a token, and background jobs (no request, no header) still need an API key.CHATGPT_AUTH_ENABLED=falseis the kill switch: the app reads it from/api/providers/statusto hide the sign-in, andget_user_provider_keysdrops a plan token it is still sent, so a shell that signed in earlier cannot keep using it — the Codex backend is undocumented and the path can stop working without notice.So a shared change is made once. Claude remains a first-class model through V1, which is why retiring its SDK cost no model coverage.
Which model, on whose key, is one function.
agents/engines.resolve_run_modelis engine → provider → model → key for every V1 runner — including the rule that a lone bring-your-own key chooses its own provider. It lived inagents/insights/setup.pyuntil content became the second runner that needed it; a second copy of that rule is the copy that eventually spends the wrong key.V2 (Google ADK) was removed. Not on framework merit — ADK is actively developed and Google-backed — but because nothing dispatched its runner:
routes/generate.pyhad been hardcoded to V1, so selecting "v2" in the UI silently served V1 while claiming otherwise. Its differentiators (Vertex Agent Engine deploy,adk web, native A2A, built-in evals) do not intersect this stack, and its weakest axis — provider breadth — is exactly what V1 exists for. Its defaults were identical to V1's, soresolve_enginefolding a stored"v2"back to V1 changed no behaviour.agents/insights/schema_compat.pyoutlived it for a while — it was never ADK-specific — but it went with insights V3, its last caller. V1 asks the provider for a typed object viawith_structured_output, so nothing needs to parse a synthesis out of raw text any more.- Ingestion: Direct Google API clients (
google-ads,google-analytics-data,google-api-python-client). Async concurrent fetching inservice/pipeline.py. - Normalization: Lightweight Python pipeline — raw API response → typed Pydantic/SQLModel brief models. No query layer or transforms yet.
- Database: PostgreSQL on Railway — SQLModel ORM, Alembic migrations,
psycopgdriver. Auth: JWT for users; Google OAuth for connector linking (Ads, GA4, GSC, Sign-In), and a GitHub App for GitHub, with a pasted fine-grained token where no App is configured (self-host). Project access is by membership (
project_members), not byprojects.user_id— always go throughservice/membership.py.validate_api_keyis not an authorization boundary.DUCT_API_KEYships to the browser asNEXT_PUBLIC_DUCT_API_KEY, so it proves "this is the Duct app" and never "this caller owns that row". A router mounted behind it looks protected and is not. Any endpoint that reads or writes a project-scoped row therefore needsget_current_userplus a membership check on top:- a project named in the request →
get_project_for_user - a row addressed by its own id →
get_project_row_for_user, which reads the project off the row. An endpoint that takes a row id and trusts aproject_idfrom the request is letting the caller vouch for themselves. - a listing → scope it to the caller (
accessible_projects), never to an unfiltered query parameter.
404, not 403, for a non-member, so the reply is not an oracle for which ids exist.
routes/artifacts.pyis the reference;routes/content.pydeclaresget_current_useron the router so endpoint 45 cannot be written without it, andtests/test_content_access.pyasserts that property directly.- a project named in the request →
- Email:
service/email/— one seam (send_email) over swappable providers inservice/email/providers/, the only place a mail vendor is named.EMAIL_PROVIDERpicks one; unset takes the first with credentials (Cloudflare, then SMTP) and falls back toconsole, which logs, so dev/CI/self-host need no vendor account. Same shape as the app's analytics seam, for the same reason: a fork swaps one file. SMTP is the open-standard provider — any mail server or vendor relay — and the way to read local mail as it would arrive: the "Mailpit" editor task (brew install mailpit) catches it on:1025with an inbox athttp://localhost:8025. Resend's own provider was removed in favour of it (#460); Resend takes SMTP too. - Observability: PostHog for product events, error tracking and AI call
metadata, behind one seam (
service/analytics.py, the only file that imports PostHog): routes callcapture_event,get_current_userattributes the request withidentify_user(the account UUID, the same distinct id the app sends), uncaught exceptions are caught by the ASGI middleware andlogger.exceptionrecords by a root handler, and the AI callback runs in privacy mode, so prompts and replies never leave. Off withoutPOSTHOG_API_KEY, and off on a loopbackAPI_PUBLIC_URLunlessPOSTHOG_ENABLE_LOCALHOSTis set. And OpenTelemetry tracing of every agent turn, model call and tool call (agents/core/telemetry.py), shipped over OTLP/HTTP to whateverOTEL_EXPORTER_OTLP_ENDPOINTnames and off when it is unset. Locally that is Jaeger (scripts/jaeger.sh, Apache-2.0; it replaced Phoenix, whose Elastic licence is not open source): the "App + API- Jaeger" and "Desktop + API + Jaeger" launch compounds start it
and point the API at
http://localhost:4318(the desktop window talks to that same API, so it needs no exporter of its own), and every FetchData and verifier dispatch is a span with its own latency. Jaeger reads thegen_ai.*attributes, lists the spans under theduct-backendservice, and keeps them on disk for a week. Its MCP server is registered in.mcp.jsonat/api/ai/mcp/on the UI port (16686), so an agent can read the traces of a slow run instead of querying the transcript table. Every log line carries a request id ([a1b2c3d4], fromX-Request-Idwhen the caller sends one, minted otherwise, echoed in the response); an agent run logs under the id of the request that created its session, so one grep follows one press of Send. Each turn ends with aturn 198.0s: 4 tool calls …line naming the slowest three, which answers "where did the time go" without a SQL script.
- Jaeger" and "Desktop + API + Jaeger" launch compounds start it
and point the API at
- Response headers:
utils/security_headers.pygives every responsenosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrerandContent-Security-Policy: default-src 'none'; frame-ancestors 'none', the last because nothing here is a page. An HTML response getsframe-ancestors 'none'alone, for FastAPI's/docs, which loads Swagger UI from a CDN. A route that needs a different value sets the header itself and the middleware leaves it. Pure ASGI likeAccessLogMiddleware, so SSE streams pass through unbuffered;tests/test_security_headers.pyholds both. - Hosting: Railway — auto-deploys from
mainvia GitHub integration;railway.jsondefines Railpack build + uvicorn start.railpack.jsonsits beside it and configures the builder, whererailway.jsonconfigures Railway. It exists for one line —deploy.aptPackages: ["...", "libexpat1"]— and every character of it is load bearing, including the"...". Railpack builds with a mise-installed CPython, then assembles a slim runtime image carrying only the apt packages it inferred from the dependency graph (libpq5, from psycopg). That interpreter is dynamically linked againstlibexpat.so.1; nothing in the graph implies it, so without this the ELF loader fails before Python starts and the container dies on its first line."..."is not decoration. Railpack arrays replace the inferred value rather than extend it;"..."is its spread syntax. Writing["libexpat1"]therefore dropslibpq5and everything else Railpack worked out. - Runtime commands must name the venv binary, never
poetry run.startCommandandpreDeployCommandboth run in the deploy image, andpoetryis not a real program there — it is a mise shim onto a private virtualenv that mise built with the builder image's system interpreter. Railpack copies/mise/installsinto the runtime image but the runtime base carries no system Python, so that interpreter is a dangling symlink and the container dies withCould not find platform independent libraries/Failed to import encodings modulebefore poetry prints a single line. Nothing in the message mentions poetry, which is why this cost three weeks and two wrong fixes. The app's own/app/.venvis unaffected — it was built by the mise CPython that is copied — so/app/.venv/bin/uvicornand/app/.venv/bin/pythonwork wherepoetry runcannot. This surfaced when Railpack's Debian base moved to trixie (2026-08-16); every backend deploy between then and the fix failed identically, and production served a June image for three months because a failed deploy leaves the old one running. The other trap: the build succeeds and the image pushes, so Railway reports a failed deployment that looks like a build failure and is not. Read the deploy logs (railway logs --deployment <id>), not the build logs. Also check the deployment is notSKIPPED—railway.json'sbuild.watchPatternsgates whether a push builds at all, so a file it does not list (this one, once) can leave a fix sitting onmaindoing nothing. JSON has no comments, which is why this note lives here. - CI: GitHub Actions (
backend.yml) — Ruff lint + pytest on every PR and push tomain. - Tests:
make testmust stay offline and under two minutes; it is the gate on every merge and the thing an agent runs after every change. Two rules keep it that way. A test that needs a provider key, a network, or a binary onPATHis markedlive— askipifon the key alone is not a gate, becauseget_configs()readsbackend/.env.local, so a developer with a key there fires a paid, minutes-long call from a plainpytest. And the V1 agent loop is driven by the fakes intests/fakes.py(ToolCallingFakeand its failing variants,fake_llm,tool_names) plus theemittedfixture inconftest.py: the real harness runs, only the model is canned. Assert on events, tool names and payloads, not on prompt prose — a wording test fails on every copy edit and catches nothing an eval would not. - The offline suite cannot open a socket.
conftest.py's autouseno_networkfixture raises onsocket.connectfor anything not markedlive, so a test that forgets to fake its transport fails naming the seam it forgot rather than passing on the author's machine and nowhere else. It blocks loopback too, deliberately: an agent sandbox or a corporate runner exportsHTTPS_PROXY=http://localhost:<port>, so a guard that waves loopback through waves the whole internet through, silently, in exactly the environments most likely to be holding a key. - Fake at the seam the vendor gives you, and put the fake in
tests/fakes.py. For the sync reporting connectors that is the vendor's ownapi()wrapper; for the retry loop underneath them it isservice/rest.py, covered directly bytests/test_rest_transport.pybecause five connectors share it and a regression there lands on all of them at once. For Google Ads it isFakeAdsClient, which keeps the library's local machinery —get_type,enums,copy_from, the GAPIC path helpers — and replaces onlyget_service. That distinction is the point: a stub made of attribute bags passes while the field is misspelled and the enum is not a member, which is the whole class of bug worth catching in code that changes what a customer spends. Building a realGoogleAdsClientrefreshes OAuth against Google at construction time, soFakeAdsClientis also the only offline way into those executors at all. GA4 has the same problem for the same reason —discovery.build()fetches the discovery document before it returns a client — andFakeDiscoveryServiceanswers the fluent chain by dotted call path ("properties.keyEvents.list"). The GTM fake intest_execution_policy.pystays where it is on purpose: it keeps container state so it can answer a read that follows a write, which is a different job from replaying canned answers. - A fetcher's test fakes the transport, not the client. The client-level
fakes above are right for an executor, where the mutation it sends is what
matters. They never run the code that builds a read request, and that is
where GA4 landing pages were broken for six weeks (
StringFilterimported from the wrong module) with every test green. So each read fetcher has a request-shape test one layer down:FakeWirereplaceshttpx.requestbeneathservice/rest.pyso a whole Meta, Apple, Stripe, RevenueCat or GitHub pull runs with the vendor's own encoding, headers and pagination real (test_rest_connector_requests.py);RecordingHttpplusdiscovery_build_offlineletgoogleapiclientbuild Search Console and the GA4 admin API from the discovery document it ships, so method names and parameters are validated offline (test_gsc_fetchers.py); the GA4 Data API test fakes onlyBetaAnalyticsDataClientand lets the real request types build the report; andFakeAdsClient.search_streamanswers the Google Ads read fetchers with realGoogleAdsRowprotos and logs the GAQL inqueries(test_google_ads_fetchers.py). When you add a fetcher, add one of these with it — assert on the request that would have gone over the wire, then on the parsed rows.test_vendor_contracts.pycovers what none of them can: that every lazily imported SDK name and every GAQL field still exists in the installed package.
Desktop (local sidecar) mode
The backend runs in two shapes from one codebase. Railway is unchanged; the desktop build runs the same FastAPI app as a sidecar on the user's machine — see the engine consolidation review (duct-cloud, private) §7–8.
- Entrypoint:
local_server.py. SetsDUCT_LOCAL=1, resolves the per-user data dir, binds 127.0.0.1 on an OS-assigned port, and prints a single JSON handshake line on stdout before starting uvicorn:{"duct_sidecar":1,"url":...,"port":...,"api_key":...,"data_dir":...}. The Tauri shell must read the port from that line — never assume one. The shell also passes--exit-on-stdin-eofwith a pipe only it holds, so the sidecar stops itself when the shell ends however it ends (SIGTERM to uvicorn, then a hard exit two seconds later). Off by default: a sidecar run by hand reads a terminal or/dev/null, and neither may stop it. - Data dir (
utils/appdirs.py): macOS~/Library/Application Support/ai.getduct.desktop, Windows%APPDATA%\Duct, Linux$XDG_DATA_HOME/duct. Created0700. Override with--data-dirorDUCT_DATA_DIR. - Local mode defaults (
Configs._apply_local_mode_defaults): SQLite at<data_dir>/duct.db, uploads at<data_dir>/uploads,init_db_on_startup=True(no Alembic on a laptop). Each is only filled when unset, soDATABASE_URL=postgresql://…still works for a developer running local mode. - Local API key: minted once by the desktop shell and held in its keychain,
handed down as
DUCT_LOCAL_API_KEY(the JWT secret travels the same way); a headless sidecar with no shell falls back to a0600file at<data_dir>/local-api-key. Exported asDUCT_API_KEYso the existingvalidate_api_keygate applies unchanged. It only stops other local processes driving the sidecar (docs/engineering/credential-storage.md). - Secrets arrive on stdin, not in the environment. With
--secrets-on-stdinthe shell writes one JSON line ({"duct_secrets":1, "DUCT_LOCAL_API_KEY": …, …}) on the pipe it then holds open for the watchdog, andbootstrap()reads it before anything else. Precedence is that line, then the environment (the headless and developer path), then the file. Once_report_environment()has built and cachedConfigs,scrub_secrets_from_environment()removes every name inSECRET_ENV_NAMESfromos.environ, including theDUCT_API_KEYandJWT_SECRETit derived — a process environment is readable by other same-user processes and inherited by subprocesses. That scrub is safe only because every consumer goes throughget_configs(); a new reader of one of those names fromos.environat request time would find it gone, so route it throughConfigsinstead. - JSON columns must use
models/columns.py::json_column(), neverpostgresql.JSONBdirectly — raw JSONB fails to compile on SQLite. The variant still renders JSONB on Postgres, so it produces no Alembic diff. - Never name a module
models/types.py— it shadows the stdlibtypes.
Before adding a table, an event kind or a log
Name which existing store already records it, and propose new storage only when none does. Four overlap on purpose and each answers one question:
| Question | Store |
| what was said, what did each tool return | agent_events |
| who did what to a project, when | activity_logs |
| what state is a change in right now | execution_change_sets |
| what the agent knows and cannot re-derive | project_memories |
The full list, one line per table, is
docs/engineering/data-model.md, and
tests/test_data_model_doc.py fails when a new table is missing from it.
Why this rule exists: on 2026-09-24 a "decision event" was designed for chat
approve/reject rows while activity_logs already recorded every one of them,
with its actor and conversation. The gap was the reader, not the record.
Database migrations
Schema changes are applied manually with Alembic — a normal local dev step,
distinct from an app deploy (the global "deploys go through CI/CD" rule is about
shipping app code, not running migrations). Nothing runs migrations
automatically: railway.json only starts uvicorn. CI does verify them —
backend.yml's migrations job applies the whole chain to an empty
Postgres 16, runs alembic check (fails on any model/migration drift, the
diff --autogenerate would have written) and undoes the newest one once.
The offline suite runs on SQLite and cannot see any of that;
make check-migrations is the same three steps against whatever throwaway
Postgres DATABASE_URL names.
- Apply: from
backend/, runalembic upgrade head. The DB URL resolves frombackend/.env.local(the Railway TCP proxy) viaconfig.get_configs(). - Inspect:
alembic current,alembic heads,alembic history. - The proxy host is not resolvable inside the command sandbox, so migration commands run with the sandbox disabled — they need outbound network to the managed database's proxy domain.
- New models must be imported in
models/__init__.pysoSQLModel.metadatapicks them up for autogenerate. - Migrations should be additive/reversible — always provide a working
downgrade.
Roadmap (not in codebase yet)
- Ingestion framework: PyAirbyte for early pilots → client-managed Airbyte later
- Query layer: DuckDB + Ibis
- Transforms: dbt
- Orchestration: Dagster
- Delivery: email through
service/email/+ Slack webhooks
Product-shape constraints
- Do not build a dashboard-first product here.
- The primary value is the brief and alert output.
- The backend should support a thin onboarding app, not depend on a rich frontend.
- Design all outputs for operator clarity: what changed, why it matters, what to do next.
Current directory structure
service/google/brief.py— Google Ads brief normalization (loads demo fromdata/<connector_id>/, defaultgoogle_ads)service/google/schema.py— typed Google Ads brief payload (dataclasses / JSON contract)agents/insights/prompts/— synthesis prompts:autonomous.pybuilds the system and user prompt,paid_ads.pyandorganic_growth.pyhold the vertical modesget_system_prompt()dispatches toroutes/auth.py— OAuth by connector (/auth/connectors/{connector_id}/oauth/...). Every connect starts with a?link=naming the signed-in user and ends with that user's page claiming the grant; both clients come home on/connections?connector=&auth_code=(the desktop one through/desktop-authand the shell's deep link).routes/signin.py— Google sign-in, and the guest:POST /auth/guestmints a realusersrow keyed on an install id so an audit can run before anyone signs in;/auth/guest/link-code+?link=on authorize lets the Google callback link the account to that guest or merge the guest into an existing one (service/user_store.py::absorb_guest, which walks the schema for owner columns rather than keeping a list). Never make an owner column nullable for this — a guest is why they need not be.?sources=onboardingon authorize is the onboarding bundle: the same sign-in also asking for the Search Console and Analytics read scopes, offline grant, consent forced; the callback stores oneconnector_credentialsrow per scope Google actually granted (service/signin_sources.py, through the same upsert the Connections page uses,service/connector_store.py). It exists for exactly one surface — the connector prompt on the onboarding audit, for a guest — and every other sign-in and every connector flow stays as it was. Never add a write scope to the bundle; a connector asks for those itself, with its justification on screen (service/connector_scopes.py).routes/audit_prefetch.py— the crawl onboarding starts the moment a URL validates (agents/audit/prefetch.py): root page now, the rest in the background, handed torun_pipelinebycrawl_id. Duct's bandwidth only; inference never runs here.POST /api/audit/documentsbeside it reads up to five files the user adds on the "Found it" card, keeps their text for the prefetch's TTL (agents/audit/documents.py) and answers one result per file; the run names them indocument_ids. It parses the form itself, after the token, the limiter and the declared length, because a declaredFile(...)body is spooled to disk before any dependency runs.service/documents.py— text out of a PDF (pypdf), Word or PowerPoint (zip + XML, any DOCTYPE refused) or plain text, with every reader stopping at a page, character and inflated-size cap. Text rather than a PDF block for the model, because several providers (DeepSeek among them) read none. A file that gives no text raisesDocumentErrorwith anUnreadablecode the app words; never a message.service/ratelimit.py— the in-process fixed window every route goes through that spends before its caller has proven much:RateLimit.enforceis the 429 withRetry-After. Key an address withclient_address(request), neverrequest.client.host, which on the hosted API is the proxy in front of it; keyed on that, a per-address limit is one bucket for everyone. A guest is a user anyone can mint, so a route that spends on a per-user limit carries a per-address one beside it. Session starts also cap live sessions per user (agents/core/session.live_session_count), and the teaser — the one run on Duct's key — is held to its lead token, its address and a follow-up budget.tests/conftest.py::fresh_rate_limitsempties every window between tests.service/clone_reference.py— a pasted TikTok link becomes a saved reference for a clone (issue #222). The link is reduced to a handle and a post id and everything after uses the URL rebuilt from them; the Apify input (single_post_run_input) takes that parsed post, one result, downloads off. A post the project already saved is reused, else it goes throughservice/discovery.py's ingest and capture. The clone itself isContentRunner.run_clone: draft_post's system prompt, one structured call on the run's own model for why the reference worked (the slides attached only onVISION_PROVIDERS), and the FIT × PROOF discipline in the USER turn.submit_post_draftwritescontent_posts.clone_sourcefrom the session's link plus the model's verdict; the approach is derived, never typed.agents/audit/draft.py— the project drafted from the crawl, in layers (crawldeterministic;documentsone structured call over the user's files, only when they added some;inferredone structured call), emitted asPROJECT_DRAFTwhen a run setsdraft_project. The documents are read in theread_documentsstep, before competitor research, and reach the rest of the run only as the business context they seed: their text never rides in the audit's own turn. Never infers the North Star; the agent asks for it in chat.service/project_draft.py— where each project field came from (projects.provenance) and what may overwrite what, the server's copy of the rulesapp/src/lib/projectDraft.jsruns for an instant answer (a test holds the ranks and field names equal). Two writers, two rule sets: a draft (POST /api/user/projects/{id}/draft,merge_draft) is a machine and lands only on a blank, a drafted field of equal or lower rank, never on the person's; a save (the PUT,reconcile_save) is the person and wins where they changed something, but a drafted value the client only carried along cannot replace an equal one or blank a field, because that is an older copy of a draft the server has since refreshed. Every new writer of project fields goes throughmerge_draft(the MCP server, a GitHub re-sync), never a direct column write; a writer the rules do not know is how a guess overwrites what someone typed. A draft cannot claimuser. The one exception is the person speaking through their agent: MCPupdate_projectwrites throughedit_fieldsasuser, whatever the field held, and records what it replaced in itsactivity_logsrow soPOST /api/user/projects/{id}/edits/{activity_id}/undo(revert_fields) puts it back, skipping any field changed since (issue #512).routes/mcp_server.py+service/mcp_server/— Duct as an MCP server atPOST /mcp, for the person's own agent (Claude Code, Codex, Cursor, OpenCode). Stateless JSON-RPC with no SDK: five methods, no sessions, no event stream. Its one credential is an MCP token (service/mcp_tokens.py), accepted on/mcponly and never byget_current_user, and there are two ways to get one. Browser sign-in (issue #197,routes/mcp_oauth.py+service/mcp_server/oauth.py): Duct is the OAuth authorization server, so an agent given only the address finds the sign-in from/mcp's 401 (RFC 9728), registers itself (RFC 7591), opens the app's/authorizeconsent page, and trades the code with PKCE for an hour-long access token and a refresh token. Three rules: registration writes no row (the client id is the registration, signed with a key derived fromJWT_SECRET, never the secret itself, or a client id would be a session); the backend renders no page (/oauth/authorizeparks the checked request inservice/auth_exchangeand redirects to the app, and an unverified client or redirect goes to the app's error state, never to the address it named); and the sidecar has none of it (oauth.available(): its address changes every launch and its app istauri://). A personal token, minted at/api/user/mcp-tokensby a signed-in session, is the way in for an agent with no browser. Both are rows inmcp_tokens, listed together with akind, and DELETE ends either. Four tools, and adding a fifth is a scope decision, not a refactor:list_projects,get_project,draft_project,update_project. A draft goes throughservice/project_writes.apply_draft, labelleddocument(orinferredfor what the agent guessed), so it never replaces the person or their site. An edit (update_project, #512) is the person asking in so many words: it can replace anything, carriesdestructiveHintso the client confirms it, and is undoable from the activity feed. Each write leaves anactivity_logsrow (category="project"). No tool calls a model on Duct's side, and none approves, applies, connects or touches credentials.agents/engines.resolve_job_run— provider and model over the keys this caller can spend, so a user holding only an OpenAI key runs on OpenAI instead of a 402 for the instance default. All three agents route through it now: audit directly, insights viaagents/insights/setup.resolve_run(which serves both the live session and the scheduled brief), content viaroutes/content._resolve_run_model.resolve_run_modelremains for callers that have no job to name.models/settings.py+service/profile.py+routes/profile.py— the operator profile: name, role, writing preset, the language Duct writes to them in, and their own instructions. Same argument as the tier map below, applied to voice. Two rules worth knowing: the server row is the truth and the request payload is the fallback (resolve), so a signed-out audit still gets the voice picked in the browser; and the preset derives the oldercommunication_style/report_depthpair rather than replacing it, so every prompt that reads those is unchanged. Rendered for a run byagents/core/voice.user_context_blockinto the<user_context>block — in the USER turn, never the system prompt, becausebuild_insights_system_promptis cache-stable and one per-customer string in it costs the cached prefix on every call.models/settings.py+service/model_settings.py— the tier map and the fallback switch, keyed by user. They used to live inlocalStorageand ride on each request, which meant the scheduled brief — the run whose owner is definitely not watching — could not read the preference its owner had set. The browser's copy is now a cache of this, and on disagreement the server wins.agents/core/quota.py— a 429 the retry loop gave up on, remembered for a few minutes so the next run resolves to a tier that can serve. Keyed by(HMAC-SHA256(process salt, key)[:16], provider)and never by provider alone: Duct is multi-tenant with bring-your-own keys, so one customer's rate limit says nothing about another's. In-process and deliberately not durable — a cooldown is a hint, and the worst case on a fresh worker is one wasted 429. Recorded inReportedRetryMiddleware._give_up, consumed byagents/tiers.resolve_tier_model'scoolingset, reported to the browser astier_skippedonPIPELINE_STARTED. Seedocs/engineering/2026-09-08-quota-aware-tier-ladder.md.POST /api/providers/{id}/verify— one real completion on the provider's Light model, classified intoinvalid_key/no_billing/model_access/rate_limited/unreachable, or for a ChatGPT credentialsubscription_quota/subscription_revoked/subscription_blocked. The only endpoint that can tell a pasted key from a working one.- The audit agent mounts the insights agent's connector tools
(
agents/core/connector_tools.py):ListDataSourcesalways,SelectAccountandRequestConnectionwhen the run has a project. The system prompt holds the rule (Search Console only, after the report, once); the onboarding audit's user turn (draft_project) is the trigger, so the cached prefix is identical across every other audit. service/mcp/+routes/mcp_servers.py+agents/core/mcp_tools.py— MCP servers a user connects (issue #345). Duct is the client here; Duct as a server is #197. Eight rules, each held bytests/test_mcp_*.py:- Our runtime, not the provider's. Anthropic's
mcp_serversand OpenAI's Responsesmcptool serve only their own models, so the client is the referencemcpSDK (2.x) in-process, the capability rule above, not fastmcp's heavier stack. Its transport takes only anhttpx2client, so everything underservice/mcp/speaks httpx2, and it turns a 401 into a generic JSON-RPC error:client._sessionwatches responses for the status instead. - A server is a credential row.
connector_type = "mcp",account_idthe canonical URL; no table of its own. A chat reaches only its own user's servers, in any project, never the project owner's: unlike a vendor key, a token reads the whole account behind it. Sessions are their creator's alone, so whoever sees a server's data and answers its card owns it. - Only a trusted preset's
readOnlyHintis believed (trust_read_only, off by default). On a server added by address every call shows the approval card: a stranger's "read-only" still carries the agent's arguments to the stranger. Infrastructure presets (Railway, Cloudflare) stay untrusted too, because their reads return secrets. The card gets the arguments whole; a call too long to show (APPROVAL_ARGUMENT_CHARS) is refused, never cut. - Sign-in is GitHub's shape. Start (signed in) parks the flow under the
OAuth
state; the callback parks the tokens as a grant; only the user who started may claim it. A client metadata document where the server takes one (its URL is the client id, served at/auth/mcp/client-metadata.json), dynamic registration otherwise, a pasted key when neither works. PKCE and the RFC 8707resourceon both legs. The metadata is held to where it was found: a resource that is not this server or a parent of it, or an issuer other than the server the document was fetched for (RFC 8414 §3.3), ends the sign-in with the key prompt. - Every request goes to a public https address, redirects included:
service/mcp/http.guarded_clientruns the crawl fetcher's resolved-address check. No stdio servers on the hosted API. - The registry is searched, never trusted.
service/mcp/registry.pyreads the MCP Registry (MCP_REGISTRY_URL, empty turns it off) and says who runs each server from the name its publisher proved: a reverse-DNS namespace that owns the host, or a GitHub login naming its site. Official means that, the site is the tool searched for, and the host is the site or one service label under it (SERVICE_LABELS), so a tenant of any platform fails closed; nothing on one that hands out subdomains (SHARED_HOSTS) qualifies, and a GitHub login vouches for nothing there. A server connected from it is a server added by address: every call asks first. - A server says why it needs its owner. A failed listing or a refused
refresh records
store.PROBLEMin the blob (signed out, key refused, not answering…); any call that works clears it, a reconnect replaces it. A failed tool call records nothing, because a slow job or one tool's 403 says nothing about the server.POST /servers/{id}/checkis the listing on demand. - Two fixed agent tools, never one per server tool.
ListConnectedTools(a menu, then one tool's schema on request) andCallConnectedTool; a call that asks first pauses onTOOL_APPROVAL_REQUIRED. Insights interactive sessions only, and only when the user has a server, so every other prompt keeps its cached prefix.
- Our runtime, not the provider's. Anthropic's
service/discovery.py— saved TikTok references.ingest_reference(db, project_id, post)is the one way aScrapedPostbecomes adiscovered_referencerow (one per post per project);capture_reference_mediacopies its cover and slides into project storage and records the outcome inparams["media"], because TikTok's image URLs carry a signed expiry. The Discover save runs capture after the response;/content/discover/recaptureis the backfill, called when Discover opens. A clone-from-URL flow reuses both. Every media fetch goes throughservice/url_safety.py: https only, a host allowlist (MEDIA_DOMAINS), redirects re-checked hop by hop, a size cap and an image content type. The URLs arrive in the request body, so without that guard a save is a request the caller aims at our network.service/apify/run_cache.py— an identical Discover search (actor + canonical input) inside 30 minutes joins the earlier Apify run instead of paying for a new one. In process; the API runs one worker.routes/generate.py—POST /api/insights/generatefor interactive brief + LangChain synthesis enveloperoutes/project_members.py— project members + email invitations (docs/engineering/2026-08-16-project-collaboration-plan.md)service/membership.py— project access checks (owner vs collaborator) and invite token handlingdata/google_ads/—google-ads-report.json(demo brief),raw/demo_raw_payload.json
Agent-type architecture
The agents/ directory is organised by agent type. Each type is independent and has its own goals, tools, prompts, schema, and versioned runners.
agents/
├── engines.py — engine/provider/model registry + resolve_run_model (shared)
├── models.py — Provider, ModelName enums (shared)
├── core/ — the ports: session registry, events, LangChain adapter (lc.py),
│ checkpointer, memory/artifact/connector/web tool binders,
│ the shared DeepSession loop, SDK shims
├── insights/ — Insights agent (paid ads + organic growth intelligence)
│ ├── v1/ — deepagents runner (the only insights engine)
│ └── catalog/, goals/, schema.py, prompts/, subagents/
├── audit/ — SEO audit agent
│ ├── v1/ — create_agent-rung session runner (build_session_agent)
│ ├── crawl.py — engine-neutral: the session, the crawl, report parsing
│ ├── enrichment.py — competitor research; create_agent + web tools, any provider
│ └── scoring.py — the report's scores and counts, computed from its findings on
│ every submit (both engines); the prompt's tables render from it
└── content/ — Content Studio (plans, posts, images, publishing)
├── v1/ — deepagents runner (the only content engine)
├── assessment.py — the pre-publish review's checks and scoring; the agent
│ scores six markers, the weights stay here
├── channels.py — one post row serves every platform; what differs is rules,
│ not columns. RULES holds each platform's limit, fold,
│ replies that publish, media and metrics support; the
│ writer, both publish paths, the review and the app (via
│ `post.channel`) all read it. A new channel is a row there
├── publishing.py — what a post sends its scheduler (service/publishers: PostBridge,
│ Zernio), the media and its bookkeeping, shared
│ by the agent's publish_post and the publish route (#272, then
│ a button that read only local disk, were the two drifting
│ apart): pick_media chooses the files (a video's cut, each
│ slide's composed render), load_media reads them through
│ service.storage (R2 or disk), send_post uploads and creates.
│ While a scheduler holds a scheduled post, push_edit / cancel,
│ which every edit, unschedule, delete and agent rewrite goes
│ through so the queue never publishes stale words or a deleted
│ post; once its time has passed, settle, which the post
│ listing runs (throttled per project) so it reads posted or
│ failed, the reason on an activity_logs row
├── text_prompts.py — X and LinkedIn draft on their own base prompt; nothing in the
│ visual playbook applies to a post that is words
├── voice_rules.py — the project's own voice (#271): writing samples, banned words
│ and the client-names / protect-the-edge switches, stored in
│ content_brand (no migration). The old prompt constants are the
│ defaults. Values ride in the USER turn; the text writer refuses
│ a banned word as it refuses an over-length post
├── plan_period.py — the period a plan manages: one post per date, at most 30. The
│ person picks the month in progress (default; under a week left
│ rolls over to the next month) or 7, 14, 30 days (PlanRequest
│ start_date + days). One plan per period: a run opened on a plan,
│ or while a plan covers its start date, manages that plan; a new
│ plan stops short of the next one; submit_plan updates in place
│ (days with a drafted post are kept). It used to insert every
│ time, so each refinement in chat left a second plan beside the
│ first. app/src/lib/contentSchedule.js mirrors these rules
├── best_time.py — when a draft goes out (#266): the account's own best hour on
│ that channel once five posts there have numbers, else the
│ channel's default, in the reader's zone, never on top of a
│ post already scheduled there. performance.py is the plan's half.
│ Its route sits beside POST /content/posts/{id}/skip, whose
│ reason becomes a feedback memory the next draft reads
├── performance.py — the account's own history for a plan: type ranking (completion,
│ saves, shares; never likes), explore/exploit, graded bets, best
│ posting times. An unrecorded metric is unknown, never zero.
│ Visual posts only: a tweet's numbers never steer a TikTok plan
└── tools.py, subagents/, prompts.py, schema.py, artifacts.py, enrichment.pyRoute convention: each agent type gets its own route prefix, and every agent's
session lifecycle runs through the unified routes/agents.py:
POST /api/agents/{type}/sessions→ stream → messages — every sessionPOST /api/insights/generate— the unattended briefPOST /api/audit/run— the audit pipeline/api/content/*— content CRUD, brand context, the slide-render bridge; the legacyplan/streamandpost/streamentry points drive the same runner
Cross-agent invocations are modelled at the frontend level (e.g. audit findings carry an invoke_insights action that pre-populates the insights wizard). Backend agents remain decoupled — no direct calls between agent types.
Agent ports — the harness boundary
We rent an agent harness; we do not marry one. The full declaration (with the
port table and the external standard behind each) lives in
agents/core/ports/__init__.py — read it before adding anything that touches a
framework. The rules it implies:
- Never write an
AgentHarnessinterface. Harnesses differ in capability, not just API shape; the intersection loses the reason to use one and the union means maintaining a framework. The harness stays harness-shaped inside a runner, and only its boundary is standardized. - Domain code imports no framework. Tool bodies, prompts, schemas, goals and
scoring are plain Python.
agents/core/memory_tools.pyis the reference shape:_remember_sync/_search_sync/_get_synchold the logic, andbuild_memory_tools_lc/build_memory_tools_sdkare thin binders. - Framework imports live only in adapters — runners, binders, and the
named shims.
tests/test_harness_boundaries.pyenforces this and lists the allowlist; adding a file to that list is a deliberate act, not a fix for a failing test. - Write the adapter on the second implementation, not the first. One
implementation is a guess. The human-in-the-loop port is the worked
example:
PauseFninagents/core/session.pywas declared only once the LangGraphinterrupt()implementation (agents/core/lc.interrupt_pause) existed beside the Future bridge. A tool body takes aPauseFn; the binder that mounts it decides which one — the Future for an agent with no checkpointer (audit, the slide-render bridge), the interrupt for one with durable threads (insights v1, content v1). Same events, same route, and the frontend cannot tell them apart. - What the user may watch an agent do is one allowlist, in one place.
Every tool call passes through
recorder_tool_hooks;agents/core/activity.pywraps that pair and emitsTOOL_ACTIVITYfor the tools it maps — a connector pull, a web search, a page read, an image, a sub-agent dispatch — twice per call (running, then the verdict), with structured fields and no prose, so the app writes the sentence in the reader's language. Three rules:- Allowlist, never denylist — and the allowlist is nearly everything.
A tool is invisible until someone writes its card, so a new tool leaks
nothing by default; but the standard is transparency, and the only calls
left off are the ones with a better row of their own:
RememberFact(the "Remembered" note, with undo), the pause tools (the cards),Create/Update/RewriteArtifact(the artifact card),write_todos(the strip), the execution proposal (the change-set card) and the audit's report builders (its step ladder). Memory searches and reads, the connector listing, opening or listing a document, every read the content agent makes of what the project holds, and everything it saves, publishes or logs are rows. A new tool goes on the list unless it has one of those other rows — say which, in the comment aboveACTIVITY_TOOLS. - The run's own context is a row too.
announce_contextrecords the CONTEXT row and emits the "Read project context · business, memory, connected sources" notice in one call, so a runner cannot do one without the other. Every runner calls it where it composes its opening turn; a resume records the row and draws no notice (the reopened transcript already has one). Memory's two events are stored as rows as well (EventKind.MEMORY_RECALLED/MEMORY_WRITTEN), so what the run recalled and remembered survives a reload for every agent. - No bespoke per-tool step events. The insights runner used to emit a STEP per data pull with an English label built in Python, which the ladder and a separate pane both rendered; that is one line said twice and untranslatable. Add a mapper, not an event.
- Every runner wires the hooks.
tests/test_tool_activity.pyfails a runner that opens aDeepSessionwithoutactivity_hooks, because the audit runner passed no tool hooks at all for months — recording nothing, showing nothing — and nothing failed. The card payload carries the notice that a tool ran, never its output: the full input and result are already in the transcript, and a card holding fetched page text would put attacker-authored bytes in every client's memory.
- Allowlist, never denylist — and the allowlist is nearly everything.
A tool is invisible until someone writes its card, so a new tool leaks
nothing by default; but the standard is transparency, and the only calls
left off are the ones with a better row of their own:
- A durable thread is the conversation. The insights runner keys its
LangGraph thread on the conversation id, so a resumed session continues the
thread — and a pause the thread is parked on comes back as the same SSE
event, flagged
replay, when a session resumes it. Never key a thread on a session id; that made "resume" a transcript the agent could not see. - A failure is a code before it is a message.
agents/core/errors.pyclassifies an exception once (classify_error), and that code decides the retry (is_retryable), rides on the failure event (error_payload), and picks the copy in the browser. Never emitstr(exc)to a client, and never add a regex on message text in the frontend — add a code, or a class name to the classifier's table. - Input during a turn is steered or queued, never refused. A harness that
can hand the model a message at its next call sets
steer_queueon its session (insights does, viaSteerMiddleware); the rest fall back tochat_queue. The route decides; the runner reportsuser_input_consumedwhen it dequeues so the client can drop the "queued" mark. Do not reintroduce the 409. - Run status is derived from the stream, in one place.
ConversationRecorder(agents/content/persistence.py) already sees every event, so it is what writesagent_conversations.run_status(RunStatusinagents/core/events.py: idle / running / paused / failed / cancelled) andrun_error, and appends afailureevent where a turn died. The list and state routes carry both; a reload shows the failure where it happened, with the same code. Do not set the status from a runner — a second writer is how two agents end up disagreeing about one column. A session closed mid-turn is recorded ascancelledbyrecorder.close()in_close_and_consolidate. A process that stops writes nothing (server shutdown'sclose_all_sessionsnever reaches a recorder, and a kill runs no shutdown), so every run stillrunningat the next boot is cancelled bycancel_orphaned_runsfrom the lifespan. It assumes one API process; a second replica would need a lease or heartbeat instead. - A retry says how long, and the provider's
Retry-Afterwins.MODEL_RETRYINGcarriesretry_in(seconds, a duration — the client anchors it to its own clock so skew cannot show a countdown already over), computed byretry_delay(attempt, exc), which readsretry_after_seconds(exc)fromagents/core/errors.pybefore falling back to the jittered schedule. A provider asking for longer thanMODEL_RETRY_HEADER_MAX_DELAYis not retried at all — the failure, with its code, is more useful now than after a countdown that fails anyway. The summariser's calls are billed withscope: compaction, so they count toward the total and never drive the gauge. - A request too long gets one compaction and one retry. The automatic
summariser works from an estimate and the provider counts for real; when
they disagree the request comes back as
context_window. The insights runner then callscompact_thread(agents/core/lc.py) — LangChain's ownSummarizationMiddlewareforced by a one-message trigger, keeping the lastCOMPACT_KEEP_TOKENS, written back "as" the tools node so the graph's next step is the request that failed — emitscontext_compacting/context_compacted, and continues from the checkpoint. A second overflow is the ordinary failure. deepagents' own summarisation event is cleared in the same write: it indexes into the message list the rewrite just replaced. Both paths — the emergency one and the automatic summariser — put the summary text oncontext_compactedand the recorder writes it as anEventKind.COMPACTEDrow, so the transcript can show what the thread now opens with, live and after a reload, instead of an unexplained gap. The automatic one is deepagents', which summarises inside themodelnode, so a node name cannot find it:is_summarization_callreads thelc_source: summarizationtag both summarisers put on their model call, and the divider goes out when the reply that runs on the summary starts streaming, not when the node's update lands after it.compaction_summaryreads the same tag off the summary message, minus either summariser's framing. - A model has a price or it has no cost.
PRICINGinagents/models.pymirrorsCONTEXT_WINDOW(a test holds them equal) andcost_usd()prices a call from LangChain's usage, taking cached tokens out of the input figure.TOKEN_USAGEand the state route carrycost_usd,Nonewhen unpriced — never a guess, because on BYO keys the figure is what the user pays. - A model that leaves the catalogue leaves a row behind. Replacing a
ModelNamevalue (Opus 5 → Opus 5.5, 2026-09-29) orphans every saved tier pick,GENERATE_MODELand stored thread that names the old id, andresolve_engine_modelsends an id it does not know to the provider default: a Heavy pick of Opus 5 would have run on Sonnet without a word.RETIRED_MODELSinagents/models.pymaps the old id to its successor — never a step up in price — and keeps the old model's own price and window, so a thread it served still reads what it cost. An OpenRouter slug whose only successor costs more gets no successor and keeps running as picked (OpenRouter passes it through).tests/test_model_transport.pyholds all three. An OpenRouter slug names a build, so check the vendor's changelog as well as the catalogue:deepseek/deepseek-v4-flashstill served, and looked current, after DeepSeek retired the build. - A model's request contract is data, not a check at a call site.
CLAUDE_BOUND_THINKING(Fable 5.1, Opus 5.5, Sonnet 5.5) answers two questions: a forcedtool_choiceis a 400, so a typed answer at the end of a tool loop goes throughagents/core/research.research_answer, never a bareToolStrategy; and a thinking block replayed after an earlier turn changed is a 400 for accounts opened on or after 2026-08-31, soresolve_chat_modelasks the API to drop it instead.takes_temperaturedrops the sampling temperature the Claude 5 family and GPT-6 refuse while they reason. A new model is classified in those tables when it joins the catalogue. - Before adding an agent or changing one — a prompt, a tool, a model
default, a middleware, a LangChain bump — read
docs/engineering/agent-engineering.md. It is the rulebook for agent work on this stack: each rule, the LangChain primitive behind it and the test that enforces it, the add/change checklists, and the ranked open gaps. Fixing a gap means adding its test and striking it from that list in the same change. - A change to an agent runs the eval gate.
agent-eval.ymlrunsscripts/agent_eval.pyon any PR touchingagents/(notagents/content/oragents/audit/, which no case covers yet: take the exclusion off when one does),service/memory.py,service/profile.pyor the lock: each case intests/eval/cases/three times on a PR with no re-run, six times on the merge tomain, on a synthetic account, DeepSeek V4 Flash on OpenRouter with GLM 5.3 Flash judging, a verdict againsttests/eval/baselines.json(make agent-evallocally, a few cents). A baseline is per model: a run on another model is INCONCLUSIVE until--write-baselinerecords one for it. The eval runs on every push to a ready PR, so open agent PRs as drafts while iterating. It is in shadow until an A/A window on unchangedmainshows no false FAIL; from then on FAIL does not merge, and INCONCLUSIVE needs one line in the PR saying why it is acceptable. A harness change also adds an offline test of what reaches the model for the invariant it touches (tests/test_request_invariants.pyis the model), never a prompt-wording test. A bug found in a real session ships with a synthetic case that fails without the fix. Never raise k, lower a threshold or delete a case to turn a run green: loosening the gate is its own PR, with the numbers. - Read the harnesses built in the open before designing a lifecycle
feature.
docs/engineering/agent-harness-references.mdis the watch-list — Codex, OpenCode, pi — with the revision each was last read at, findings pinned tofile:line, and the gaps they expose in ours, sized. Pauses that survive a reconnect, typed error codes, steer-versus-queue input and visible compaction all have a worked answer there. Refresh the table when you read one. Pick the lowest rung that works. LangChain 1.x is layered, and the layers carry different stability guarantees:
init_chat_modelandcreate_agentare on the semver-stable 1.x LTS surface (no breaking changes until 2.0), whiledeepagentsis 0.x with no stability policy and a weekly cadence. Reaching fordeepagentswherecreate_agentsuffices buys churn for nothing.The rung is a property of the agent, not of the agent type, and it can move when the agent's job does. The autonomous insights session (
agents/insights/v1/runner.py) is ondeepagentsbecause it needs four thingscreate_agentlacks — a planning loop, subagents, skills, and theinterrupt_onupgrade path — and the phase plan spends all four. The content session (agents/content/v1/runner.py) spends three of them from its first turn:write_todosis the checklist the workspace renders,research_pillaranddraft_postare sub-agents, and the virtual scratch space holds drafts. Audit's V1 runner is still on thecreate_agentrung, and content's enrichment pass (agents/content/enrichment.py) is one too — search, fetch, structured answer, no planning.The rung is chosen; the middleware is not. A session graph is built by
agents/core/deep_session.py—build_deep_session_agentor, on thecreate_agentrung,build_session_agent— and both mount the samesession_middlewarelist (pruning, call limits, fallback, reported retry, steer) plus provider prompt caching. The audit was a barecreate_agentfrom 2026-08-16 to 2026-09-27: itsRunLimits(added 09-06) were declared and never enforced and Anthropic never cached its prefix, all tests green.tests/test_agent_assembly.pynow fails a graph built anywhere else; a bounded one-shot pass (the two enrichment modules) is listed there and must still passprompt_caching_middleware().Three consumers of the 0.x pin now, so
tests/test_deepagents_harness.pymatters more, not less: run it before moving the pin.tests/test_content_v1_runner.pypins the content contract the same waytests/test_insights_session.pypins insights.deepagentsis pinned exactly, not with a caret — it changes behaviour in minors (task planning became opt-in in 0.7).tests/test_deepagents_harness.pyis the upgrade gate; run it before moving the pin.
Artifact contract
The app lists top-level *.json briefs in data/google_ads/ (e.g. google-ads-report.json) for local dev; user-generated insights are returned from POST /api/insights/generate and stored client-side (localStorage).
The JSON contract:
source_metadata.theme— theme key (paid_ads,product_intelligence,organic_growth); the app resolves accent colors from thissource_metadata.generated_at— ISO 8601 timestamp- All other fields follow the typed models in
service/google/schema.py
Do not write HTML from the backend. Do not reference themes.json or HTML templates — those have moved to the app.
Code design rules
- Normalize first, synthesize second.
- Keep typed schemas central and explicit.
- The backend is a data pipeline, not a renderer. Output JSON; let the app handle presentation.
- Separate ingestion, normalization, synthesis, and delivery concerns.
- Prefer extensible evidence models so future tools can enrich the same findings.
Shared helpers — check here before writing a local copy
Each of these replaced a family of divergent duplicates (23 private
_utcnow() definitions, five hand-rolled retry loops). A new local copy
re-opens that drift, so reach for these first and extend them when they
don't fit.
utils/dates.py—utcnow(),now_iso(),parse_iso(),last_n_days(). Neverdatetime.now()ordatetime.utcnow(): every persisted timestamp is UTC-aware, and naive ones compare and serialise inconsistently across SQLite (desktop) and Postgres (Railway). Stdlib-only leaf module, somodels/can usedefault_factory=utcnowwithout an import cycle.utils/strings.py—slugify(),titleize().utils/formatting.py—money(),number(),percent(),multiplier(),safe_divide().service/memory.py— agent memory (project_memories):remember()is the ONLY write path (it redacts secrets, dedupes, honours the pause switch, and closes the previous value of a state key),search()is the only read path (Postgres FTS, SQLite LIKE), andbuild_memory_context()assembles the prompt blocks. Never write the table directly and never re-render the digest locally — the supersession and never-raise contracts live in that module.service/memory_consolidation.pyowns the post-session extraction pass; its model output is a proposal that still goes throughremember(). Tools for both harnesses are inagents/core/memory_tools.pyand the shared prompt stanza isagents/core/prompts.py::MEMORY_DISCIPLINE. Per-project memory goes in the USER message, never the system prompt.search(time_aware=True, rank=True)is the question-shaped read — it reads a date range out of the words, treats a named kind as a filter, matches on ANY term and then tightens (all terms → two → one), and ranks by relevance + recency + importance + recall. Leave both off for a filter form like the timeline, whose inputs are the user's explicit instructions. Retrieval makes no model calls, by design.tests/eval/memory_recall.pyholds the 50-question set (pytest tests/test_memory_retrieval.py -sprints the per-axis report); it exists because it caught the AND-everything query bug that made questions retrieve nothing, so extend it before tuning retrieval by feel.Private memory is private whatever the role, and the rule is in the service, not in the routes.
remember()stores aPERSONAL_KINDSentry (identity, communication, method) as user scope even when the caller said project, because an agent in a project run noticing how someone works is describing that person;feedbackis left out on purpose, since a skipped draft's reason is about the project's output. A private row may be bound to one project (private_to_project=True: "for this client, brief me in Spanish"), so it carries that project's id, which is why every project read goes throughshared_rows(), neverproject_id ==alone. A bound row is read only on runs for its project, never replaces the general one, and keys its state to its person so two teammates can bind the same key.search()never returns the preference keys the profile now owns (_PROFILE_OWNED_KEYS). Held bytests/test_memory.py.The kinds are twelve project and four personal (
models/memory.py).ruleis a standing policy, keyedrule:<topic>so a new one replaces the old, and every rule in force leads the digest's Pinned section;outcomeis what a change did, keyed by the change and the window measured. Merged kinds (LEGACY_KINDS: milestone, process, tooling) are mapped on write and insearch(), so an old name still works; add to that map rather than deleting a kind outright.Six rules about when memory is read and written, each held by
tests/test_memory_context.py,tests/test_memory_phase2.pyortests/test_memory_retrieval.py:build_memory_context()returns its blocks inMEMORY_BLOCKSorder and applies the agent'sContextSpecitself. A turn carries the result as one string in theproject_memoryslot, so memory's tags stay adjacent inBLOCK_ORDER. A block the spec switches off is never queried.- The digest budget is whole entries,
DIGEST_MAX_CHARS(about 1,500 tokens) andDIGEST_MAX_ENTRIES, spent in section order: pinned, open, relevant to this question, recent, artifacts. An entry that does not fit is left out and is not reported as recalled. - Recalled means cited or opened, never shown.
ConversationRecordercredits them_…ids a reply cites (touch_cited), and GetMemory credits what it opens. Counting the digest fed its own ranking. - Consolidation does not depend on a close. Close flushes early; the
sweep in
service/memory_consolidation.py(every five minutes, for conversations quiet for 30) is what guarantees the pass after a deploy, crash or quit desktop app. It runs onJob.MEMORY, the owner's Light tier. "Don't remember this" is stored on the conversation (record_remember_choice), because the sweep never sees the session. The sweep can spend only saved keys, so an owner whose keys live in the desktop keychain or a browser tab is covered by keys they lend instead: a closing session lends its caller's header keys (LentKeys), and the app callsPOST /api/user/memory/catch-upwhen it opens and every 30 minutes (app/src/lib/memoryCatchUp.js). Lent keys are held in memory, never stored, and spent only on projects the lender owns, because the owner pays for background work. Plan tokens are never lent: a structured one-shot call cannot spend them. - A thread re-reads memory at most hourly, and only when something changed.
memory_refreshcompares the conversation'smemory_primed_atwith the newest memory row, excluding the thread's own writes;routes/agents.pyasks on each message, and the session holds when to ask next. - A state outside Duct is shown with its age, never as "present". A
campaign's status, a budget, a rolling metric renders
seen <date> · Nd, and past its entity's shelf life (FRESHNESS_TTL_DAYS)verify, whichMEMORY_DISCIPLINEturns into a re-read before the entry is stated as current. Remembering the same fact again stampsmeta.verified_atand starts the age over; a consolidation pass does not, because a transcript that repeated memory is not a sighting. Derived at read time (volatility(),freshness()), so no column. The freshness axis of the memory eval holds the stale-assertion and false-alarm rates at zero.
agents/core/turn.py— how every agent's user turn is built. An agent declares aContextSpecinagents/registry.py(which shared blocks it wants); a run renders aTurnContext;build_turnorders them. A new agent that declares nothing gets everything — business context, the operator's profile, stored agent context, prior reports, memory, data sources — which is the right default, because an extra block costs a few hundred tokens and a missing one costs an agent that does not know who it is answering. That was not hypothetical:display_namereached insights and content and silently missed audit for a release, because audit described the operator its own way.Two rules this module exists to hold, both enforced by
tests/test_turn.py:- Per-user and per-project text goes in the USER turn, never the system prompt. The system prompt is the cached prefix; one customer's name in it gives every account a prefix of its own and loses the hit on every call of every run. The test builds a profile of distinctive strings and fails if any of them appear in a system prompt.
BLOCK_ORDERis stable-first, volatile-last, and it is a cache decision rather than a formatting one. Inside one session the order is free (turn one is the prefix for turn two either way). It pays across runs: two insights runs on the same project a week apart share a system prompt, and if both turns open with byte-identical<business_context>and<user_context>the cached prefix extends past the system prompt into the turn. Lead with the memory digest instead and the match ends at the first block, because memory moved in between. Do not "tidy" that tuple into declaration order.
An agent with a block of its own may set an unlisted tag — it renders after the ordered blocks and before the request. Anything a second agent starts using belongs in
BLOCK_ORDER, where its cache position is a decision somebody made on purpose.scripts/session_bundle.py— pull one stored session (the context the model read, every tool call with its payload, artifact versions, memories, cost) into a folder for review;make session-bundle ID=<conversation id>orID=list. Read-only, redacts on the way out, and the folder is customer data: it goes in the private audit home, never here. Thesession-auditskill (.agents/skills/session-audit/) is the review procedure built on it. The pull is only as good as what the recorder wrote:EventKind.CONTEXT(one row perrun_session, written by the runner that composed the turn — the insights runner today) holds the composed opening turn, its blocks and the system prompt's fingerprint, because the USER row is only the sentence the person typed. A runner that assembles its own turn and does not record a CONTEXT row cannot be audited against what its model saw; give it one before relying on the skill for that agent.scripts/session_replay.py— re-run a bundled session on exactly its own data with the current prompt and any model (make session-replay BUNDLE=<dir>). FetchData is seeded from the bundle and the connectors are closed (replay=inbuild_data_tools_lc: a pull the original never made returnsnot_in_replay), no recorder, no persister, no memory or execution tools. It is how a prompt proposal is checked against the run that motivated it before it ships, and it spends a real model call.scripts/dump_prompts.py— regenerate after any prompt change (make dump-prompts). It renders every agent's system prompt and a sample assembled turn todocs/engineering/agent-prompts.md, checked in, so a prompt change lands in the pull request as prose rather than as a Python string edit a reviewer has to assemble in their head.prompts.ymlruns the--checkform on a PR that touched a prompt, andmake check-backendruns it locally.We looked at the prompt file formats (Prompty, dotprompt, POML, BAML) and did not adopt one. They solve prompts-as-swappable-config, edited outside the repository and hot-reloaded without a deploy. Duct's prompts are composed:
agents/content/prompts.pyalone has 47 interpolation sites and 22 branches, audit assembles its scoring table fromagents/audit/scoring.pyand varies its tool guidance on whether the provider does vision. Moving that into Handlebars or Jinja moves real logic into a template language with no types and no tests. The reviewability problem was never the storage format — it was that nobody could read the assembled result. That is what the dump fixes.The output must stay deterministic: no timestamps, no run ids. A non-deterministic byte fails
--checkon every unrelated pull request, and a check that cries wolf gets switched off.agents/core/lc.py— the LangChain adapter every V1 runner shares:resolve_chat_model(model transport) andstream_agent(LangChain stream → theAgentEventvocabulary), plusbuild_ask_user_tool, the LangChain half of the human-in-the-loop port. Extracted fromagents/audit/v1/runner.pywhen insights became the second V1 runner. A V1 runner should not talk toinit_chat_modelor driveastreamitself. A one-shot typed answer goes throughstructured_output, notwith_structured_output: it asks Claude for its own JSON-schema output, since the integration's default forces a tool call that Fable 5.1 refuses.agents/insights/totals.py— Duct adds up a pull; the model quotes the sum.fetch_entityputstotals,ratesand per-dimensionsubtotalson every row report, from the catalog'sagg,ratioandweight, and says intotals_coverwhether they are the window's or a floor, and whether the source flagged the report itself: a fetcher puts a source's own caveats indata_qualityas sentences (GA4's thresholding, sampling, "(other)" roll-up and date truncation, from the response metadata), and the cover points at them. A replay of a real brief reasoned right and added wrong (2,504 against 2,681 sessions); summing is now never the model's job. A ratio metric is total over total, never a mean of rates (CPA weighted by conversions would drop every row that spent and converted nothing). A new averaged catalog field declaresratioorweight, ortests/test_insights_totals.pyfails.agents/core/compaction.py— lossless payload compaction, applied to a connector result before it reaches the model. Folds a homogeneous row array to typed CSV; verifies the result structurally and discards a fold that lost anything, becauselossless_onlydoes not gate every path in the library underneath (an array of identical strings is still collapsed). Off is not "the model sees everything" — off is the mid-structure cut inagents/insights/data_tools.py, which on a 900-row pull drops about two thirds of the numbers. Absent dependency degrades to plain JSON, so a self-host build without the Rust extension still runs. Reached from aUserPreferencesflag (context_compression, default on) threaded through the runner the same wayartifact_formatis.service/artifact_store.py— versioned artifact persistence.ArtifactPersisterwraps a runner's emit and stores everyARTIFACT_VERSIONevent; an adapter (ArtifactVersion+ aCallable[[dict], ArtifactVersion]) reads one version out of whatever payload that agent emits. A new agent writes an adapter — audit's validates anAuditReport, insights' reads a written brief — and never a second persistence path. Storing is the store's job; understanding the payload is the adapter's.agents/tools/execution_tools.py— the staged-execution tool surface, as a binder pair (build_execution_tools_lc/build_execution_mcp_server) over shared descriptions, arg schemas and domain functions. The surface is deliberately asymmetric: propose, inspect and roll back exist; approve and apply do not, in either harness. Autonomy (ask | assisted | auto) changes how often an agent interrupts, never what may auto-apply —service/execution/ policy.pyis the one place that decides, and it does not consult the model.service/connectors.py— the registry and the adapter contract. Alist_accountsrow has a canonical half the browser reads and a native half it never does:account_id/account_namename the thing being picked (the GA4 property, the GTM container — not its parent account, which several rows share), and the optionalentity_url/entity_detail/entity_meta(built withentity_facts) are what the picker renders as a favicon, a disambiguating line and short chips. Vocabulary is server-side too (ConnectorMeta.entity_noun), so adding a connector stays one registration rather than a registration plus an edit to a table in the frontend.ConnectorMeta.server_only_keysnames credential keys no request may write, and both routes that take credentials from a body refuse them. It exists for the GitHub App (service/github/app.py): Duct's private key mints a token for any installation id it is handed, so an id a browser could store is someone else's repository. The only writer is the claim route, bound to the user whose signed-in session started the connect. The four Google connectors'refresh_tokenis server-only for the same reason and by the same route shape (issue #456):POST /api/user/connectors/connect-linknames the user, the OAuth callback parks the grant,POST /api/user/connectors/claimstores it merged over the row (service/connector_store.store_oauth_grant, so a reconnect keeps the MCC id). No Google token reaches a client — not in a redirect, a response or browser storage — and every reader (/api/insights/refresh, execution, FetchData) resolves from stored rows; there is no request override left.ConnectorMeta.settings_keysis the opposite list: non-secret keys the user may change on a stored row (PATCH /api/user/connectors/{id}), today only the Adslogin_customer_id, shown back assettingson the row.service/content_metrics.py— a content post'sperf, read and written one way.METRIC_ALIASESreconciles the three key conventions in the column (PostBridge'sview_count, migratedavgWatchTime, hand-enteredsaves; Zernio's adapter writes the canonical names, and leaves out a metric it reports as 0 where the platform reports none, because unknown is not zero); read throughmetric_value, neverperf.get("…"). Two writers:merge_manual_metrics(the/metricsroute) records typed-in metrics undermanual_keys, andmerge_synced_metrics(the sync route and the agent'slog_metrics) skips every alias of those, so a sync never overwrites a number somebody typed.app/src/lib/contentMetrics.jsmirrors the table andtests/test_content_metrics.pyholds the two equal.service/rest.py— sync HTTP transport for the reporting connectors: retry, backoff, rate-limit pacing, error typing. A new connector declares anEndpointand anApiErrorsubclass and writes no transport code; auth headers, query encoding and pagination stay vendor-side. Not forservice/apify,service/post_bridgeorservice/zernio— those are async, hold a long-lived client, and need no retry.service/ollama.py+Provider.OLLAMA— open models on the instance's own Ollama server (OLLAMA_BASE_URL), the one chat provider with no key: the server is the credential (ProviderKey.source == "instance"), reachable on hosted and local alike once the operator sets it, and only when it holds a model that calls tools. Ids are namespacedollama:<tag>, checked beforeprovider_of's "/" test because a Hugging Face tag has one. Three rules, held bytests/test_ollama_provider.py: the pickers list what the server holds (/api/tags, filtered on/api/show'stoolscapability), never a catalogue; every call sendsnum_ctx(OLLAMA_CONTEXT_LENGTH, held to the model's trained length), because Ollama's own 4,096-token default silently cuts the system prompt and tools; andreasoninggoes only to a model that thinks, since Ollama refuses it from one that cannot. A local model is priced at zero, not unknown.service/compatible.py+Provider.OPENAI_COMPATIBLE— an OpenAI-compatible endpoint a user brings (vLLM, llama.cpp, LM Studio, Together, Groq): ChatOpenAI at their address. Its credential is the address, an optional key and the models they chose (with each one's reported context length) packed into one string (oaic1.…), so it is stored, sent asX-Provider-Compatibleand lent to background work exactly like a key;app/src/lib/ endpointCredential.jspacks the same bytes, and a shared fixture holds the two equal. Ids arecompat:<the server's id>, checked before the "/" test (a vLLM model is named by its Hugging Face repo). Four rules, held bytests/test_compatible_provider.py: the address is user input, so off a private network it must be public https, judged on the name when entered (canonical_base_url), and every request after is resolved once, judged and connected to the address that passed, real hostname kept for TLS (_PinnedTransport, so DNS rebinding has no second lookup to win), with discovery read under a size and time cap; a private address only where the network is the user's own (desktop sidecar, local dev, orPRIVATE_MODEL_ENDPOINTS); connecting proves a tool call (POST /providers/openai_compatible/probe), because a model that answers in words cannot run an agent; a typed answer asks for a JSON schema first (constrained decoding, which a small model follows where it ignores a forced tool call) and falls back to a tool call; and the price is unknown (None), since a hosted endpoint bills and Duct cannot say what.service/publishers/— social schedulers as connectors (PostBridge, Zernio). Nothing publishes by default: a project publishes through a scheduler its owner connected, each linked account names the scheduler that holds it (content_social_links.publisher), and a sent post records its own (content_posts.publisher,publisher_post_id), because only the scheduler holding a queued post can change or cancel it.base.pyis Duct's vocabulary (Publisherprotocol,RemotePost,RemoteStatus,PublisherError,PostAlreadyOut) and every caller speaks only it; each adapter lives beside its client (service/post_bridge/publisher.py,service/zernio/publisher.py) and translates the vendor's ids, statuses, replies and metric names. What a scheduler can do differs per platform and is data on itsPublisherSpec—replies(PostBridge: one on X and Threads; Zernio: a thread on X and Threads, a first comment on LinkedIn, Instagram, Facebook),metrics(Zernio reports LinkedIn and X too),strips_links(PostBridge on X) — whichchannel_payloadfolds into what the app reads, sochannels.RULESkeeps only the platform's own facts. Adding a scheduler is an adapter, a spec inregistry._SPECS, aVendorKeyand a transport fake intests/fakes.py; the routes, the agent's tools and the Accounts tab read the registry. Adapters speak REST, not the vendors' MCP servers: publishing runs the typed checks, the approval and the bookkeeping around a call, and settling, metrics and the Analytics tab run with no model in the loop at all. A scheduler Duct does not support is still reachable through the MCP client (service/mcp/), behind its approval card.service/vendor_keys.py— a third-party key each user brings for themselves (PostBridge, Zernio, Apify). A request spends the key its project's owner saved; the instance's env key only whereallow_server_provider_keys()holds, because on a hosted instance it is Duct's account and every signup would be spending it. A new user-facing vendor declares oneVendorKeybeside its client (with acheck: the cheapest read that proves a pasted key works) and joins_vendor_keyinroutes/content.py; the routes, the app'sVendorKeyFormand the tests are already generic. Never read the vendor'sConfigskey directly. A vendor's 401 is about that key, so it never reaches the browser as a 401: the app signs the user out on one.
Sequencing rules from the plans
- Validate the brief/report shape before building heavy automation.
- Start with one customer, one tool, one brief/report end-to-end.
- Add connectors and orchestration only after the output format is useful.
- Add real-time anomaly detection after the scheduled brief flow works.
What not to build yet
- no custom auth in backend
- no full Airbyte platform management
- no heavyweight job system beyond the planned orchestration layer
- no broad dashboard experience
- no complex cross-tool logic before the single-source MVP is producing useful output
Configuration
config.py is the source of truth for what the backend reads; .env.example
is its documentation, and tests/test_env_example.py keeps the two honest.
The reason that test exists is worth knowing before adding a setting: every
field in Configs has a default. A missing variable therefore never fails —
the feature it powers silently does nothing. That makes an undocumented setting
undiscoverable rather than broken, which is how .env.example drifted to
covering about a third of what a running instance sets.
So when you add a setting:
- If it is a credential (matches
api_key|_secret|_token|_dsn|password|client_id|encryption_key|jwt_secret), the test requires it in.env.example. That is not bureaucracy; it is the only signal a new contributor gets. - If it is read from
os.environdirectly rather than throughConfigs, add it toNOT_CONFIG_FIELDSin that test with the reason. A bare exemption is a hole in the check. - Renaming a field means renaming it in
.env.exampletoo — the stale check catches it, because a wrong example is worse than a missing one.
Names are shared across processes deliberately. POSTHOG_API_KEY is read by
this backend and by the desktop sidecar (only when the shell passes it, on the
user's Preferences switch); GOOGLE_DESKTOP_OAUTH_CLIENT_SECRET is read here
and compiled into the Tauri shell via option_env!.
One value in .env.local serves all of them — do not invent a DUCT_-prefixed
variant for the desktop half.
Creating a migration
Always create Alembic revisions with autogenerate — alembic revision
--autogenerate -m "..." or python scripts/migrations.py revision .... Do not
hand-write a revision file. Autogenerate diffs the models against the live
schema, which is the step that catches a column you added to a model and forgot
to migrate; a hand-written revision cannot notice what you did not think of.
Review the generated file before applying it — autogenerate is a good first
draft and a poor final one, especially for renames and server defaults.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

