taos-development-skill
Procedures and architecture for contributing to
taOS. The non-negotiable rules live in
soul.md; this skill is the how.
uses approximate counts (~N) as rough orientation only - actual numbers rot fast in a living repo. Trust the tree, not tallies.
Repository layout
<clone>/
tinyagentos/ ← server package
app.py ← FastAPI app factory: lifespan, route registration
config.py ← Platform config, hardware detection
routes/ ← one APIRouter module per feature area (~127 modules)
templates/ ← minimal: agent_debugger.html only (frontend is React SPA)
channel_hub/ ← framework-agnostic messaging: connectors + MessageRouter
adapters/ ← per-framework agent adapters (generic ~20 lines, acp ~500); each declares verification_status
cluster/ ← distributed compute: worker registry, task routing, GPU lease
worker/ ← cross-platform worker apps (system tray, Android, iOS)
*_store.py, base_store.py ← data layer: top-level BaseStore subclasses (aiosqlite); one SQLite file per store; no stores/ dir
chat/ projects/ mcp/ ← chat, project board/canvas/A2A, MCP proxy+permissions
installers/ containers/ ← model/app installers; Docker + LXC backends
migrations/ ← DB migrations
desktop/ ← React + TypeScript SPA (Vite)
app-catalog/ ← YAML app manifests + catalog.yaml
tests/ ← pytest suite (large; counts rot - trust the tree)
docs/ ← documentation; agent manual compiled from docs/agent-manual/
Key architectural patterns
- Routes - each
routes/*.pyis anAPIRouterregistered intinyagentos/routes/__init__.py'sregister_all_routers()(called fromcreate_app()) withdependencies=_csrf.async defhandlers,awaitall I/O, Pydantic request/response models. Routes access stores viarequest.app.state(dependency injection set up in the app lifespan) - they do not import stores directly. No cross-importing between route modules. - Stores - SQLite via
aiosqlite; each subclassesBaseStore(tinyagentos/base_store.py), setsSCHEMA(first-open DDL) andMIGRATIONS, and is attached torequest.app.statein the lifespan (app.state.secrets, …). Never reference a migration-added column insideSCHEMA. - Config -
AppConfigdataclass inconfig.py; YAML serialisation; async-locked saves viasave_config_locked(); typed backends (rkllama,ollama,openai,anthropic, …). - Templates - all real UI work goes in the
desktop/React SPA. One legacy Jinja template exists (agent_debugger.html); if you must touch it, match its existing Pico CSS + htmx style. - Frontend - React + TypeScript SPA in
desktop/. Built with Vite:npm run buildoutputs tostatic/desktop/(gitignored). For development:npm run devserves with hot reload on port 5173. One concern per component; API calls in dedicated hooks or service files. - Cluster - worker registration, routing to remote nodes, model archive/promotion on capable hardware, GPU lease claim/release, hardware-tier compatibility.
Git workflow
Before starting
Sync from upstream. In a fork clone,
originis your fork (push target) andupstreamisjaylfc/taOS(fetch/rebase target). Add upstream once:git remote add upstream https://github.com/jaylfc/taOS.git. Always rebase onto the canonical branch, never your fork's possibly-staledev:git fetch upstream dev git checkout dev git rebase upstream/devNever branch from a stale
dev.Create an isolated worktree (recommended for concurrent work):
git worktree add /path/to/worktree dev cd /path/to/worktreeWhen done:
git worktree remove /path/to/worktree(or--forceif needed).Create a branch:
feat/<slug>orfix/<slug>matching the task.
After completing the work
Commit before anything else. Code → test →
git add→git commit(conventional message) → push to fork.Open a draft PR, then mark ready immediately:
gh pr create --repo jaylfc/taOS --head <user>:<branch> --base dev --draft \ --title "feat(scope): description" --body "Fixes #<issue>. Tests: N/N pass." gh pr ready <PR#>Fork CI approval is per-contributor: a FIRST-TIME contributor's workflow runs sit in
action_requireduntil a maintainer approves (mark ready once the CODE is done and local tests pass, then surface the approval need); a RETURNING contributor's CI runs automatically - no approval wait, and you own making it green (gh pr checks <PR#>: test 3.12/3.13 + lint + spa-build all pass) before considering the task done.Never commit directly to
devormaster. All work happens on branches. Main only receives merges via upstream PR approval.
Branch naming
feat/<slug> or fix/<slug>. Keep it short and descriptive.
Commit messages
Conventional commits only:
| Prefix | Use for |
|---|---|
feat: |
new feature |
fix: |
bug fix |
docs: |
documentation only |
refactor: |
code change with no behaviour change |
test: |
adding or updating tests |
chore: |
tooling, deps, CI |
No AI tool attribution in commit messages.
Testing
Run targeted tests first, then the fast parallel gate. Never run the un-parallelised full
suite (pytest tests/ -v) locally - it is massive and will take far too long. CI owns the
full 3.12–3.13 matrix (3.11 on nightly cron only).
# 1. Targeted - the changed module + related tests, always first:
uv run pytest tests/test_<changed_module>.py tests/<related>/ -v
# 2. Canonical local gate - parallel, run before marking ready:
uv run pytest tests/ --ignore=tests/e2e -n auto
Dependency-audit ignore hygiene
security/pip-audit-ignore.toml suppresses advisories that have no released
fix. scripts/check_dependency_audit_ignores.py (run by
.github/workflows/security.yml on PRs and a Monday cron) re-evaluates the
list every run so it cannot rot: it probes uv lock --upgrade-package per
ignored package to see whether a fixed version now resolves, and runs
pip-audit WITHOUT the ignore flags to catch any finding not on the list.
If it fires: a resolvable fix means take the upgrade and drop the entry; a
new advisory means triage it, never blanket-add it. Entries for tool-level
deps the project does not pin set check_upgrade = false.
Test conventions
conftest.py:tmp_data_dirfixture creates temp config + SQLiteappfixture:create_app(data_dir=tmp_data_dir)clientfixture:AsyncClient(transport=ASGITransport(app=app))- async HTTP test client- Module mirroring:
tests/test_routes_agents.pytestsroutes/agents.py - SPA stubs: conftest creates stub
index.html/sw.jsso tests don't neednpm run build - E2E (Playwright) tests excluded from CI and local gate
CSRF in tests - ON by default
verify_csrf runs for real in tests. It used to be no-op'd by an autouse fixture for every
file whose path lacked the substring test_csrf - measured at 788 test files, exactly ONE
inside that carve-out, so 787 ran against an app whose CSRF dependency did nothing. That is
what hid #2081: a repro written as an ordinary test returned 303 and PASSED, and the tell was
that the CONTROL passed identically, which is the shape you get when the input never reaches
the system under test.
What this means when you write a test:
The shared
clientfixture already does the right thing. It echoes thecsrf_tokencookie intoX-CSRF-Tokenon mutating requests, exactly astaosFetchdoes in the SPA. Nothing to remember.If you build your own
AsyncClient, it will 403 on mutating routes once it carries ataos_sessioncookie. Fix it the way the real caller does - send the header - not by disabling the check. One line does it:from taos_test_csrf import csrf_event_hooks # tests/taos_test_csrf.py AsyncClient( transport=ASGITransport(app=app), base_url="http://test", cookies={"taos_session": token}, event_hooks=csrf_event_hooks(), # <- echoes the cookie into the header )It lives in its own module, not in
conftest.py, becausetests/is not a package and severalconftest.pyfiles exist, so a barefrom conftest import ...binds whichever one is onsys.pathfirst.verify_csrfexempts safe methods,Authorization: Bearercallers,_CREDENTIAL_PATHS(the sign-in surfaces), and any request with notaos_sessioncookie. A test that never authenticates is unaffected.@pytest.mark.csrf_bypassexists but nothing uses it, andtests/test_csrf_bypass_debt.pyasserts the list stays EMPTY. Adding a marker turns that guard red on purpose. Do NOT add it to silence a new red: a red means a route the real caller could not reach the way your test reaches it - give the client the event hook instead.A filename no longer changes behaviour. The old carve-out was a substring match on the path, so renaming a file silently re-armed the bypass with no failure anywhere.
Test markers
Every pytest marker is declared once, in pyproject.toml under
[tool.pytest.ini_options] markers. Do NOT register one from a
pytest_configure hook in tests/conftest.py: that file already defines the
canonical hook, and a second module-level def pytest_configure is last-wins
rebinding rather than additive registration, so the earlier body never runs and
its next edit is a silent no-op in CI.
@pytest.mark.skip_if_no_embed_backendskips a test that cannot run without an embedding backend — a reachable qmd service, or an installedonnxruntime. There is no opt-out marker and no-oswitch: not applying it IS the opt-out, and that is the default for every test.- Like
csrf_bypass, nothing uses it, andtests/test_embed_backend_marker_debt.pyasserts the list stays EMPTY. Before adding it, check what the test actually calls. A test driving anAsyncMock(spec=httpx.AsyncClient), a hand-built_snapshot, or a patched_run_setupnever reaches a backend, so the marker does not protect it from anything — it just deletes it from every CI row while the suite stays green. - Probe the capability, not a proxy for it. The backend check opens a socket
against the packaged default qmd URL and looks for
onnxruntime(what executes a model), notonnx(the model-format library taOS does not depend on) and not an environment variable no module undertinyagentos/reads. A proxy answers "no backend" on a box that works and "backend" for a host that does not exist.
Patch timing matters if you ever stub it yourself: register_all_routers does
from ... import verify_csrf and freezes the object into Depends(...) at include_router
time, so patching the module attribute AFTER create_app does nothing.
CI matrix
- Python 3.12 + 3.13 on every PR/push; 3.11 on nightly cron only
- GitHub Actions:
.github/workflows/ci.ymlin upstream repo - Uses
uv sync --frozenandpytest -n auto - Also required:
spa-build(npm build + tsc + vitest - a desktop type error or failing component test fails CI), a "Verify app starts"create_appimport smoke,lint(compileall), andcla. The doc-gate, store-wiring gate, bot-review gate, distrust-green gate, and evil-merge gate (.github/workflows/evil-merge-gate.yml, implementation inscripts/check_evil_merge.py— fails a PR whose merge resolution invents test-file content differing from thegit merge-treebaseline; the workflow selftests that it fires RED on the known evil mergead5cdfb0cbefore checking the PR) are separate workflows. check-all-skip(.github/workflows/distrust-green-gate.yml, implementation in.github/scripts/check_all_skip.py) fails a PR when a test file it adds or modifies has tests and ALL of them skip (e.g.pytest.importorskipon a module that does not exist yet) — green CI that asserts nothing. The failure names the file and the guard. Landing tests ahead of code stays legal via an explicit waiver trailer in the PR body:Tests-Skipped-Intentionally: <file>, <why>. Files with SOME skips pass (v1 scope).- Gate integrity (
.github/workflows/gate-integrity.yml, implementation inscripts/check_gate_integrity.py): because eachpull_requestgate checks out the PR merge ref and runs its checker FROM that checkout, a PR can edit its own gate to always-exit-0 and green-pass the check that gates it. This workflow runs onpull_request_targetfrom the base ref and inspects the PR diff via the GitHub API only -- it never checks out or executes PR code. It fails any PR touching.github/workflows/,.github/scripts/,scripts/check_*.py,docs/doc-gate.toml,pyproject.toml, ortests/conftest.pyunless the PR carries the human-setgate-integrity-allowlabel (the only way to land a legitimate change to CI or a gate checker). Add that label yourself; lanes must not add it. Branch protection must require "Gate integrity" as a blocking check. Retargeting a PR re-runs this gate, and it can newly fail. The verdict is a function of the base..head diff, so a PR that passed against one base can touch protected files against another; the workflow subscribes to theeditedactivity type (which is what a base change fires) so the new diff is actually inspected. If you change a PR's base and this gate goes red, that is the new diff being judged, not a flake -- re-read what the failure names.
CLA - HUMAN signs
taOS requires a Contributor License Agreement for first-time contributors. The CLA bot
flags the PR with a cla: fail check.
The agent does NOT sign the CLA. Posting the acceptance text accepts a legal agreement - that is the human account-holder's action, not the agent's.
Procedure when cla: fail appears:
- Verify the commit author email matches a GitHub-verified email. If the email was wrong, amend the commit with the correct email, force-push.
- Surface the bot's comment + link to the human. Do NOT post the acceptance text yourself.
Do NOT post
recheck.
The bot accepts a PR comment in this format (for the human to post):
I have read the CLA Document and I hereby sign the CLA
PR / CI flow (fork specifics)
- After creating the draft PR, check both
gh pr checks <PR#>andgh run list --repo jaylfc/taOS --branch <branch>- the matrix run may not show inpr checkswhile it awaits approval. - If the CI run shows
action_required: the first-time-contributor workflow-approval policy is blocking it. Surface this to the human - do not re-poll, re-push, or re-create the PR. Lightweight checks (CLA, Gitar, CodeRabbit) run independently and don't need approval. - Once code is done and local tests pass,
gh pr ready. Address review feedback with additional commits on the same branch. The maintainer merges upstream.
Do not spend turns waiting
You pay per turn. Spend them on judgement (reading a diff, diagnosing a failure, deciding whether a green check is trustworthy), not on waiting or repetition.
- Do not poll CI in a loop.
gh pr checks <PR#> --watchblocks in one call and returns when the run finishes. Re-runninggh pr checksevery minute costs a turn each time and tells you nothing new. (A malformed watch once exited early and reported success while shards were still running, so read the final status rather than trusting that the command returned.) - If you have hand-verified the same property twice, write the test. Two manual checks is the signal. A test costs nothing per run; re-reading costs every time.
- Ask the narrow question. A targeted
grepor a symbol-level diff againstorigin/devusually decides the matter far more cheaply than reading a whole diff. Work out what single fact settles it, then fetch only that. - Do not re-poll a blocked run. See
action_requiredabove: surface it and stop. Repeatedly checking a run that is waiting on a human is pure cost.
Post-Push Bot Review Cycle
After pushing a PR and marking it ready, automated bots review it. The reliable gate is
Kilo Code Review + Gitar. CodeRabbit is unreliable - a "pass" check can be a rate-limited
no-op, so never treat a CodeRabbit pass alone as evidence of review (its findings, when it does
run, still get folded). Qodo (qodo-code-review) appears on old PRs but is paused. Address all
findings before surfacing the PR for human maintainer review.
The rate-limited no-op is now also machine-gated: .github/workflows/bot-review-gate.yml
(implementation in scripts/check_bot_review.py) fails the bot-review-gate check when the
only CodeRabbit output on a PR is a rate-limit stub, and a companion re-run-on-stub-comment
job re-runs the gate against the PR head SHA when a stub comment lands after the initial
run went green. A red bot-review-gate check means the PR has no substantive CodeRabbit
review yet — wait for (or retrigger) a real review; do not merge on the
stub. A lead may waive a known rate-limit/stub false positive by applying the
bot-review-allow label (see below).
Enforcement parity is a GitHub-side branch-protection setting, not in-repo config:
bot-review-gate is REQUIRED on master but only ADVISORY on dev (absent from dev's
required_status_checks.contexts), so a red check can merge through dev and block only at the
dev->master promotion. The hardening target is to add bot-review-gate to dev's required
contexts too; that edit is Jay's standing GitHub configuration (master is left unchanged) and
is not performed by a repo commit.
Two things to know before applying it (both recorded in the workflow header):
- An override label (
bot-review-allow) ships first.scripts/check_bot_review.pyfails on a CodeRabbit rate-limit stub, which is an infrastructure condition, not a code problem. Making the context required ondevbefore there is an escape hatch would block every merge todevfor the length of a rate-limit window. Thebot-review-allowlabel (lead-applied, never by automation) waives the stub-only FAIL verdict to exit 0 with an explicit WAIVED message -- it covers only the stub verdict class (EXIT_STUB), not a cannot-fetch ERROR, so fail-closed is preserved. The script reads the label from the API at run time (never a stale event payload) and the workflow re-runs onlabeled/unlabeledso the waiver is revokable. - Use the right API shape. The contexts endpoint takes a top-level
contextsARRAY. A-f required_status_checks='[...]'string field is the wrong shape and the update silently does not apply. Send{"contexts":[...]}viagh api -X PATCH ... --input <file>, carrying the branch's existing contexts plus the new one -- the call replaces the whole list.
Procedure
- Push PR and mark ready. Wait ~10 minutes for bot reviews to complete.
- Pull bot findings - reviews AND inline comments, all bots. The login FORM differs by API
surface, so filter accordingly: the GraphQL command (
gh pr view --json) returns BARE logins (kilo-code-bot,gitar-bot,coderabbitai,qodo-code-review), while the REST command (gh api .../comments) returns the[bot]-suffixed form (kilo-code-bot[bot]). The GraphQL filter below uses a substringtest()so it matches the bare form; the REST command does not filter, so the suffix is harmless there.
Check which commit SHA each bot actually reviewed - a "pass" on a stale commit is not a pass on your head.# Review summaries + issue comments (GraphQL, bare logins): gh pr view <PR#> --repo jaylfc/taOS --json reviews,comments --jq \ '(.reviews[], .comments[]) | select((.author.login? // "") | test("kilo-code-bot|gitar-bot|coderabbitai|qodo")) | {login: .author.login, body: .body}' # Inline (line-anchored) review comments (REST, [bot]-suffixed) - Kilo/CodeRabbit post most findings here: gh api repos/jaylfc/taOS/pulls/<PR#>/comments --jq \ '.[] | {login: (.user.login? // ""), path, line, body}' - If issues found: fix all findings in a single commit, re-run local tests, push, then go back to step 1 (max 2 cycles). If findings still persist after 2 cycles, stop - do not loop further; surface the remaining findings to the human.
- Only block for maintainer review when bots are clean - 0 CRITICAL, 0 WARNING - and fold EVERY finding, nits and suggestions included. If a SUGGESTION is genuinely not applicable, note the rationale in a PR comment before blocking.
Severity tiers
| Tier | Action |
|---|---|
| CRITICAL | Must fix before blocking for review |
| WARNING | Must fix before blocking for review |
| SUGGESTION | Fix or explain why not applicable |
PR lifecycle discipline (fold-first, rebase, closures)
The review pipeline only works if the open-PR set stays small and every finding gets closed out. These rules are load-bearing; the maintainer gates on them.
Fold-first: findings outrank new work
If ANY of your open PRs has an unaddressed maintainer fold list or bot finding, addressing it comes BEFORE opening a new PR. Priority order each work session:
- Fold open findings on existing PRs (maintainer comments first, then bot findings).
- Rebase any of your PRs that show CONFLICTING against the base branch.
- Only then start a new slice.
A finding folded within hours merges the same day; a finding left while you open new PRs stalls the whole train behind it (the maintainer will not merge past an open finding, ever).
Folding means code AND a reply: answer every numbered item in the PR thread with the commit that addresses it or a concrete rebuttal. Code pushed without in-thread replies leaves the fold formally open and the verdict at HOLD.
Bot review freshness is part of folding: check WHICH commit a bot actually
reviewed. A rate-limited or stale "SUCCESS" on an older head is not a pass -
after pushing fixes, re-trigger the review (for CodeRabbit: comment
@coderabbitai review).
Rebase cadence and the stale-base rule
- dev moves fast. When your PR shows CONFLICTING, rebase onto current dev promptly - a CONFLICTING PR is invisible to the merge queue.
- A green CI run computed BEFORE the base branch moved is STALE. Two individually green PRs can conflict semantically with zero textual conflict (see the #2009/#1932 App-key incident, fixed in #2041). If dev advanced since your last CI run, rebase (or push an empty commit) so CI re-runs against the current base before asking for merge.
- Keep your open-PR count small (aim under 10). A wide-open set guarantees most of it is permanently CONFLICTING and re-review effort is wasted.
Closing PRs: always link the successor
Never close a PR silently. In the closing comment state exactly one of:
- "Superseded by #NNNN" (and confirm every still-open finding from this PR is in the successor's scope), or
- "Landed via #NNNN" (when the content merged through another PR), or
- "Abandoned because ".
A close without a successor link reads as lost work and forces the maintainer into git forensics (this happened with #1927/#1924 - both were legitimate "landed via" closures that looked like data loss for hours).
Asking another team's agent a question
taOS depends on sibling services with their own maintainer agents (taOSmd, the website). Their contracts are theirs to state, so ASK rather than inferring from their source (pitfall 23). How to reach them depends on where you sit:
- On the A2A bus (internal agents): post on the relevant channel, name the agent, and expect a reply inside the hour.
- Outside the bus (external contributors): open an issue on
jaylfc/taos-agent-commons, the private invite-only coordination repo. Label itcontract-questionand name the service. @taOS-dev sweeps it hourly and relays to the owning agent on the bus, then carries the answer back. jaylfc/taosmdis public with issues enabled, so a taosmd contract question can also go straight there.jaylfc/taos-websiteis private, so commons or the relay is the only route.
If a question sits unanswered for more than about two hours, escalate by also raising it on the PR. The relay is a person-shaped hop and can stall; silence should never be mistaken for progress.
This arrangement is TEMPORARY scaffolding. It retires when an external contributor can hold a taOS identity and reach the bus directly, which is the same capability as agent sharing. Do not build tooling that assumes it is permanent.
Verify before you claim, and compile before you PR
- An assertion with a tolerance (
abs(a - b) <= n) does not test an invariant. It cannot tell correct behaviour from total failure. Assert equality and assert the resulting state (pitfall 20). - Mocking internals or injecting unreproducible errors is fine. Mocking an external service CONTRACT is where tests lie. For sibling services (taOSmd, the website) the contract has a reachable owner on the A2A bus: ASK them rather than inferring from their source. Every mock failure in the #2062 cycle was a guess at something one message would have answered. External contributors ask in the PR and the lead relays. For genuine third parties, capture a real response and commit it rather than composing a fixture from what your code expects. Then keep one feature detecting integration test per contract, or mark the mock provisional in code with its source and date. A follow-up issue does not count: it separates the caveat from the code. A green test over a fictional contract certifies the bug (pitfall 23).
- Typecheck or run the thing before opening the PR. A frontend change that does
not compile wastes a full review round, and the executor now gates on
tsc --noEmitfor exactly that reason (pitfall 21). - When the base branch has moved under you, rebase and read what changed before resolving conflicts. Taking the wrong side of a hunk silently reverts fixes that were just merged (pitfall 22).
Scope honesty per slice
- The PR body states exactly what it ships versus what its issue scopes. Any deferred part gets an explicit deferral note AND a follow-up issue filed in the same push; the parent issue never auto-closes on a partial slice.
- Before committing,
git statusmust show only intended source changes - runtime artifacts (anything underdata/) never enter a commit, and a new component that writes underdata/gitignores its directory in the same PR (pitfall 16).
One PR per slice
- A fix and the test that proves it belong in ONE PR (pitfall 13 in docs/contributor-pitfalls.md).
- Never open a sibling PR for a slice that already has one. If a fresh branch is genuinely needed, open the new PR, link it, and close the old one with the supersede note in the same action.
Read docs/contributor-pitfalls.md before every PR - fold lists reference its items by number (for example "pitfall 5").
Common fix patterns
- New route:
routes/<feature>.pywithrouter = APIRouter()→ register intinyagentos/routes/__init__.py::register_all_routers()withdependencies=_csrf, honoring the ordering comments there (e.g.agent_registrybefore the genericagents/{name}route); do not addinclude_routerinline inapp.py→ tests intests/test_<feature>.pyusing theclientfixture. - New store: subclass
BaseStore→ setSCHEMA(first-open DDL) andMIGRATIONS(never reference a migration-added column inSCHEMA) → attach in the lifespan asapp.state.<name>→ mock in conftest if needed. - Config field: add to config dataclass → update defaults +
to_dict()/from_dict()→test_config.py. - Catalog entry:
manifest.yamlunderapp-catalog/<category>/<id>/→ add tocatalog.yaml→pytest tests/test_catalog_sync.py. - Writing a state file: call
atomic_write_text/atomic_write_bytesfromtinyagentos.atomic_io(it creates the parent dir, fsyncs the temp file and the parent directory, appliesmodebefore the rename, and randomises the temp name). Never hand-rolltmp.write_text(...)+tmp.replace(target)— that fsyncs nothing, so a power cut brings the file back the right size and full of NULs, which is the 2026-08-21 account-store wipe.tests/test_config_atomic.pyfails the build on a new copy; a promotion that genuinely cannot useatomic_io(a symlink swap) is waived in place with# atomic-io-exempt: <reason>(pitfall 24). Fromasynccode, wrap it:await asyncio.to_thread(atomic_write_text, ...)-- the fsyncs are blocking syscalls and stall the event loop. - Creating one-time key material: call
atomic_create_bytes, notatomic_write_bytes. A write is a durable replace, so two processes that both find the key file absent both write and the last one wins; the loser keeps encrypting/signing with material that is not on disk.atomic_create_bytesclaims the name withlink(2)and returns whatever actually persisted. - Debugging a test: confirm it uses the async
clientfixture and thattmp_data_dirsetup is complete; check the store'sinit(); isolate withpytest <path>::<test> -v.
Frontend CSRF contract (session-authenticated SPA calls)
Every mutating route requires X-CSRF-Token on cookie-session requests (verify_csrf is attached
router-wide). Any SPA fetch that POSTs/PUTs/PATCHes/DELETEs must attach the double-submit header:
use withCsrf(init) from desktop/src/lib/csrf.ts, or the taosFetch wrapper
(desktop/src/lib/taos-fetch.ts) which applies it automatically. A raw
fetch("/api/...", {method:"POST"}) passes vitest but 403s "CSRF token missing" in
production - this exact class shipped as a bug (#1977). Bearer-token
(agent JWT) calls are CSRF-exempt; only cookie sessions need the header.
pytest used to miss this class too, and no longer does - see "CSRF in tests" above.
Agent auth model (Bearer JWT vs session)
Agents authenticate with grant-gated registry JWTs (Authorization: Bearer ...), which are
CSRF-exempt; human sessions use cookies + the CSRF header. Access derives from an ACTIVE grant
per (agent, project) - not from token claims - and no-grant yields an existence-hiding 404.
Agent-facing routes must ALSO be allowlisted in tinyagentos/auth_middleware.py (see Pitfalls).
Onboarding surfaces: project invites (routes/project_invites.py, URL + PIN), OS-level agent
invites (/api/agents/invites), and the auth-request consent flow. Source of truth:
docs/agent-coordination.md section "Agent API surface (scoped registry JWT)".
Adapter verification vocabulary
Every adapter in tinyagentos/adapters/__init__.py declares
verification_status: tested | beta | experimental | broken (plus tracking_issue, required for
broken). tested and beta are the verified tiers (verified_only filtering returns both);
broken blocks the deploy wizard. A new or edited adapter must set this correctly - defaulting a
new framework to experimental is the norm until it passes round-trip verification.
Version sync (release bumps)
Four files carry the version and must stay identical: pyproject.toml, tinyagentos/__init__.py,
desktop/package.json (all 1.0.0-beta.N), and uv.lock's tinyagentos entry in PEP 440
normalized form (1.0.0bN). Only pyproject vs uv.lock is test-gated
(tests/test_version_lock_sync.py); the other two drift silently, so check all four on any bump.
Documentation gate
A gate blocks PRs that change certain feature code without a matching doc update
(configured in docs/doc-gate.toml). Rules marked any change also fire on a plain
modification; the rest fire only when a matching file is added or deleted. Test files
(test_*.py, *.test.*, *.spec.*, __tests__/) never trigger any rule.
| Change | Fires on | Requires editing |
|---|---|---|
Desktop app under desktop/src/apps/ |
add/remove | README.md |
Route module under tinyagentos/routes/ |
any change | docs/agent-coordination.md |
Installer under tinyagentos/installers/ or scripts/install* |
any change | README.md |
Manifest under app-catalog/ |
any change | README.md |
tinyagentos/auth_middleware.py (agent-token route allowlist) |
any change | docs/agent-coordination.md |
Anything under tinyagentos/ or desktop/src/ |
any change | CHANGELOG.md or a changelog.d/*.md fragment |
Agent registry, token auth, scope-requests store, routes/agent_*.py, tinyagentos/mcp/ |
any change | docs/agent-manual/*.md or docs/agent-coordination.md |
.github/workflows/*.yml, pyproject.toml, CONTRIBUTING.md |
any change | this skill or docs/*.md |
routes/desktop.py, routes/desktop_control.py, routes/taos_agent.py |
any change | .claude/skills/taos-agent/*.md or docs/agent-manual/*.md |
update_runner.py, auto_update.py, restart_orchestrator.py, scripts/collate_changelog.py |
any change | docs/RELEASING.md, a runbook, or another docs/*.md |
tinyagentos/worker/ |
add/remove | tinyagentos/worker/README.md |
The changelog rule is the one that catches most PRs: any non-test change under
tinyagentos/ or desktop/src/ needs a changelog.d/<pr>-<slug>.md fragment (preferred
over editing CHANGELOG.md directly, which conflicts between PRs).
.github/workflows/doc-gate.yml is authoritative (a local --no-verify does not bypass it) and
also runs scripts/check_schema_migrations.py (the SCHEMA-before-migrations guard, see Pitfalls).
If your PR trips a rule and there is genuinely nothing to document, add a trailer:
Docs-Reviewed: no user-facing change, internal refactor only
The trailer passes every rule for that PR, so it is an escape hatch, not a shortcut:
the gate prints doc-gate: trailer override used in <sha> by <author>: <why> in its CI
log for each commit that carries one, and that line is reviewable. A reviewer may ask for
a real doc instead.
Run scripts/install-git-hooks.sh to enable local hooks (.githooks/pre-commit and
.githooks/commit-msg) so the gate runs before you push.
Store wiring gate
A gate (.github/workflows/store-wiring-gate.yml, running scripts/check_store_wiring.py)
blocks PRs that add a new BaseStore subclass without wiring it into tinyagentos/app.py.
Routes reach stores ONLY via request.app.state, so an unwired store is unreachable dead
code. The check is name-level (the class name must appear in app.py) and polices only
classes added by the PR - pre-existing orphans are skipped.
A class that some other class under tinyagentos/ subclasses and that declares no
SCHEMA of its own is skipped too, and the gate prints the exemption. Such a base exists
to be inherited from, never to be assigned to app.state - ProjectsDBStore in
tinyagentos/projects/tx.py carries the shared projects.db transaction helper for the
eight stores on that file, owns no tables and is never instantiated. A class that declares
SCHEMA owns tables, so it is a store and stays policed however many subclasses it grows:
subclassing an unwired store does not launder it past the gate.
For a store genuinely constructed elsewhere (tests, CLI, workers), waive it with a PR-body trailer, which is logged by the gate:
Store-Unwired-Intentionally: <ClassName>, <why>
Secret-ignores gate
A gate (.github/workflows/secret-ignores-gate.yml, running scripts/check_secret_ignores.py)
verifies that the committed .gitignore still protects known secret-shaped paths on every
promotion target. A .gitignore rule is the kind of file a rebase conflict can quietly drop
during a dev->master promotion while every test stays green and nothing builds red, so the
protection is asserted here, not assumed. The gate runs on push to master, dev and
release/* (a dropped rule fails the branch it lands on) and on PRs to those branches (a
conflict-resolution loss fails before the merge, since the merge commit's .gitignore is what
is checked).
Two signals, defense in depth:
- Every required protection rule must appear verbatim as an active line of
.gitignore(*.key,identity.json,*.p8,*credentials.json,*creds*.json, the*_private.*key shapes,secrets/,data/hub/, and the rest listed inREQUIRED_PATTERNSin the script). Comment prose and narrower sibling rules do not satisfy a rule. - A set of secret-shaped paths (
data/hub/identity.json,foo.key,creds.json,x.p8,y_credentials.json, ...) must all be reported ignored bygit check-ignore.
Removing any one protection pattern turns the gate red -- proven by a parametrized test that
drops each pattern from a copy of the real .gitignore and asserts the guard fails.
Upstream conventions (from CONTRIBUTING.md)
- Target branch is
dev, notmaster.masteris the stable live-install track. - Branch naming:
feat/<slug>orfix/<slug> - Conventional commits (see table above)
- No AI tool attribution in commits
- Python 3.11+ floor (pyproject.toml:
>=3.11,<3.14).match/caseandX | Noneunion syntax are available. Most modules usefrom __future__ import annotations. - Code style: match surrounding code, one concern per module
- Use
uvfor dependency management and test running:uv sync --extra dev,uv run pytest - Read
CONTRIBUTING.mdsections "Verifying your work" and "Avoiding collisions with other contributors". They are the canonical statement of both; this file does not restate them so the two cannot drift. What follows is only the part specific to working here as an agent.
Verification (agent specifics)
Green is a claim, not evidence. The repo-level cases are in CONTRIBUTING.md. These are the ones that bite agents in particular:
- Do not report work as done without a PR link. A branch with commits and no PR is not delivered. A lane once announced completion on an empty branch; the check now runs before any completion is claimed, and the same standard applies to you.
- A PR that passes CI can still be empty. Before saying a card is finished, look at the actual diff and confirm it contains the change described.
- Never infer merge conflicts from
git merge-treeagainst branches you have not fetched; it produces confident nonsense. Use GitHub'smergeable_state, and treatunknownas "not computed yet" and re-query rather than as an answer. - Check the exit status of the command that matters, not the last one in a pipe.
cmd | tail && echo OKprints OK whencmdfailed. - An empty fetch is not a match. If two files that cannot be identical hash the same, your request failed rather than the contents agreeing.
- Verify a bot finding against the code before folding it. Automated review is often wrong and always confident. Fold what is real, say plainly what is not.
- Report honestly. If tests fail, include the output. If a step was skipped, name it. "Done" means verified.
Desktop SPA build + test
cd desktop
npm install # Node.js 22+
npm run build # tsc -b && vite build → outputs to static/desktop/
npm run test # vitest (unit/component tests)
npm run test:e2e # Playwright browser tests (needs running server)
Adding an app to the catalog
- Create directory:
app-catalog/<category>/<id>/ - Write
manifest.yaml(use `app-catalog/agents/langroid/manifest
…(truncated)