Operating instructions for Claude Code instances working in this repository.
Read this fully before touching the Foundry API. It is self-contained — it does not
depend on any locally-installed skill. (If ~/.claude/skills/foundry-transcripts/ or
manage-kb-collection/ happen to exist on your machine, they cover the same ground in more
depth, but never assume they are present.)
-
Every corpus in this repo is the source of truth for its Foundry collection. All five agents' knowledge now flows from here — including Tyler Identity, which was cut over on 2026-08-24. Edit here, then upload. Never edit a collection's content in the Foundry UI: that creates drift the repo cannot see, and the next upload from here silently reverts it. Check for drift before assuming you are in sync.
-
Never commit credentials. No API keys, tokens, or passwords in any file, including knowledge files.
.gitignoreblocks the obvious names but is not a substitute for checking. The Foundry API key lives only in the environment. -
Never commit a RAW transcript dump. Reviewable transcripts under
transcripts/are tracked on purpose, but only ever as written byscripts/fetch_transcripts.py, which redacts credentials, staff emails, and tokens on the way in. Raw API output goes to a scratch dir outside the repo. Never hand-write a transcript file or bypass the script — agents have been observed reproducing knowledge-base credentials verbatim in answers. -
mainis protected. A pull request is required for every change, including from admins. Zero approvals are required, so you can merge your own PR — but you cannot push tomaindirectly. Branch, PR, merge. -
A Foundry write is a production change. Collections and configs back live agents. Confirm with the user before uploading, deleting, syncing, or changing any config. Never do it as a side effect of another task.
Nothing reaches Foundry until it is MERGED to
main— not merely PR'd. Runpython3 scripts/preflight_upload.py <files>first; it refuses anything whose bytes differ fromorigin/main. Uploading from an unmerged branch puts content in front of users that the repo has not accepted, and then the next drift check tries to undo it. This has happened once — a 6.5-hour window on 2026-08-25 — which is why the check exists.THE REVIEW UI ALREADY PUBLISHES ON MERGE — do not describe the upload as a manual step. Approve & Merge on the Change Requests page merges, fast-forwards the checkout, uploads every
Knowledge-*file the request touched, syncs once and verifies by retrieval; it reports the whole action as failed if the upload does not land. An admin's periodic sync additionally publishes anythingmainhas that Foundry does not, which covers a merge done on github.com. The merge button IS the confirmation this rule asks for — the click is the admin's decision to ship. Sopublish_to_foundry.pyat a terminal is the FALLBACK (no key in the server's environment, or a merge nobody clicked through here), not the route. It also writes the team routing prompt when the request changed it, with the full guardrail chain — see Changing the team router prompt. The one thing it does not do is close out transcripts (mark_pushed.py), becausemainis protected. ReadDAILY-WORKFLOW.md§Admins before answering any question about when Foundry gets updated.After EVERY PR merge, run
python3 scripts/check_foundry_drift.py. A merge is the momentmaingets ahead of the live agents, and nothing else notices: a knowledge file only reaches an agent when someone uploads it, so an unshipped change leaves the agent answering from old text while the repo looks correct. It covers all five collections, every shared file against every one of itsupload_targets, and the team router. Report what it says even when the answer is "in sync". -
Contributors own knowledge content. Admins own routing. The split matters because the blast radius differs: a bad knowledge edit gives one wrong answer, while a bad routing edit misroutes every conversation — and the transcript that reveals it looks like a content problem, so it gets misdiagnosed.
OWNERSHIP IS PER CORPUS. Each
Knowledge-<Domain>/folder belongs to one or more contributors, andagent-owners.jsonsays who. An owner owns everything under their folder,_START_HERE.mdincluded. Where a corpus has several owners, any one of them can approve a change to it — they do not have to wait on each other..github/CODEOWNERSis generated from that file —python3 scripts/gen_codeowners.py, with--checkin CI. Do not hand-edit it. Ownership used to be recorded inagent-owners.jsonand enforced by a CODEOWNERS that gave every corpus to the whole contributors team, so the intent and the permission disagreed and nothing noticed.Contributors MAY change, and are expected to:
- anything inside the
Knowledge-<Domain>/folder they own,_START_HERE.mdincluded - their review verdicts under
transcripts/
They MAY ALSO edit another agent's corpus and open a change request for it — that is how feedback reaches an owner, and it is supposed to work. It simply cannot merge without that owner approving.
scripts/check_folder_ownership.pysays so on the request rather than letting the reviewer discover it at approval time.ADMIN-ONLY. The list is
.github/admin-only-paths.txt— read it rather than remembering it. In summary:README.md(the team routing table),team-config/,Knowledge-Shared/, and the machinery —CLAUDE.md,contributor-initial-prompt.md,transcripts/README.md,transcripts/ONBOARDING.md,scripts/,templates/,.github/,.gitignore,contributors.json,agent-owners.json.Knowledge-Shared/is admin-only because its files upload to all five collections: a change there alters what every agent says, which is the blast radius of a routing change with none of the visibility._START_HERE.mdIS NOT ADMIN-ONLY, and the reason it used to be was wrong. This file claimed the cross-agent hand-off rules in it were "team-level routing". Verified false on 2026-08-27: the team router hastoolIds: [],mcpServers: []and no knowledge base at all — it decides purely from itssystem_prompt, so it never reads_START_HERE.mdand that file cannot influence team-level routing. What it actually carries is within-corpus routing plus sub-agent hand-off advice, which is the same kind of content as the corpus around it.Keeping it admin-only also created an instruction nobody could satisfy: this file tells contributors to update
_START_HERE.mdwhenever they add or remove a corpus file, while the admin-only list denied them the permission to do it. A contributor could not complete a compliant change.If you are running for a contributor, do not touch the admin-only paths. The reason is specific to you: an agent that edits its own instructions then follows the edited version for the rest of the session, and nobody reviewing the PR can tell which rules you were operating under. Improving a doc feels helpful and is exactly the move to avoid.
Found a real problem — a wrong command, a stale count, a contradiction? Say so in your response and in the PR description. Do not fix it. Being right about the problem does not make the edit yours to make.
What none of this can enforce: nothing stops team-level routing advice being written inside a knowledge file — "for identity questions, use the Identity agent" in an FAQ is routing content in a file a contributor may edit. Path-based rules cannot see it. So when you review or write content edits, read them for routing claims and flag them. Human review is the only control there, and pretending otherwise is worse than knowing it.
Enforced by CODEOWNERS plus branch protection (server-side; a PR cannot weaken it), a CI tripwire, and
scripts/start_review_session.sh. All three read one boundary —check_admin_paths.pyasserts CODEOWNERS andadmin-only-paths.txtagree. Do not edit the tripwire to get past it: same violation, one level up. - anything inside the
-
NEVER broaden access without asking. The approved grants are exactly two teams:
onetyler-tcp-pm-admins(admin) andonetyler-tcp-pm-contributors(write), plus named individuals fromcontributors.json.The admin/write split is load-bearing, not cosmetic. Branch protection exempts administrators — it has to, because a PR author cannot approve their own PR and the sole code owner would otherwise be permanently blocked on his own changes. So admin = bypasses review; write = subject to review. Anyone whose PRs should be approved must be on the contributors team. Adding a reviewer to the admins team silently removes the gate from them. Do not infer an access model by copying it from a reference repo or a sibling project — derive it from the actual contributor list. Anything wider than the repo owner and his own team needs explicit confirmation first. This repo's owner does not work across the rest of Tyler engineering; the scope is divisional. Since SecureGuard, org-wide cross-divisional access is not possible anyway — everything is divisionally protected — so an org-wide grant is both unwanted and ineffective. A public repo needs no grant for outside contribution: people fork and open a PR.
-
ALWAYS back up before changing anything in Foundry, and commit the backup. Backups live in the repo forever —
team-config/backups/<object>-backup-<YYYYMMDD-HHMMSS>.json, never in a scratch dir, never deleted. Fetch the current object, scan it for credentials, commit the backup, and only then write. This applies to every config object that exists only in Foundry — teams, agents, collections metadata — because there is no other copy and no undo. Knowledge-file content is already covered by git history, but a config object is not.
Knowledge corpora for the OneTyler Cloud Living Foundry Team agent and its five
sub-agents. Each Knowledge-<Domain>/ folder is a deployment surface: its files are what
one agent retrieves from its tenant knowledge-base collection.
There is no application code, build, or test suite. The deliverables are content files.
See README.md for the team-level routing model and _START_HERE.md in each folder for
within-corpus routing.
Before the user's first real request, in this order. It takes well under a minute and it is what makes a session usable rather than nearly-usable.
The instructions change. Reading them once is not enough, and an agent operating from last week's understanding will confidently apply rules that no longer hold. This repo's rules have changed materially on consecutive days — what a contributor may edit, whether review header fields are required, and what must happen before anything reaches Foundry have all moved.
At the start of every session, after pulling main, check what moved and re-read it:
git log --since="3 days ago" --name-only --format="%h %ad %s" --date=short -- \
CLAUDE.md README.md contributor-initial-prompt.md contributor-update-prompt.md \
transcripts/README.md transcripts/ONBOARDING.md \
scripts/ templates/ .github/ .gitignore contributors.json \
'Knowledge-*/_START_HERE.md'Quote the last pathspec — git expands Knowledge-*/ itself, and an unquoted glob fails in zsh
when no directory matches literally. (Do not try to build this list from
.github/admin-only-paths.txt: those are regexes, not pathspecs, and converting them produces
a glob that silently matches nothing.)
Anything listed, re-read in full — not the diff alone. A diff shows what changed but not what it now means in context, and the surrounding paragraph is usually what carries the intent.
Widen the window if you have been away longer, and if git log is unavailable for any
reason, re-read CLAUDE.md, transcripts/README.md and .github/admin-only-paths.txt
anyway — they are short and it costs less than acting on a stale rule.
Say what you found in your first response, even when the answer is "no instruction changes in the last 3 days". That tells the user which rules you are operating under, which is the thing they cannot otherwise see.
New contributors join the team and cannot then be named as a reviewer, because
contributors.json is generated from team membership and the review UI offers only what is
in it. A reviewer who cannot pick their own name is stuck before they start.
python3 scripts/sync_contributors.py --check # exits 1 if drifted
python3 scripts/sync_contributors.py # rebuild, then commitRun the --check first. If it reports drift, regenerate and commit the result — the file
is only useful to the next person if it lands in the repo.
- Uses your own
ghcredentials; there is deliberately no shared PAT. If it errors, the token probably lacks the scope:gh auth refresh -s read:org. - Sources are the
onetyler-tcp-pm-adminsandonetyler-tcp-pm-contributorsteams. - Never hand-edit
contributors.json— the next sync overwrites it and a hand-added entry confers no repo access anyway. - This syncs from the team. It does not add anyone to a team: that is broadening access and needs the user's explicit say-so (hard rule 7).
- If the sync cannot run at all (no
gh, no network), say so plainly rather than proceeding as though the list were current — a stale list silently blocks the new contributor.
Use the launcher, not the server directly. It also updates the repo, refreshes the reviewer list and fetches new transcripts, which are steps 1 and 3 of this list:
python3 scripts/start.py # http://127.0.0.1:7777, opens the browser
python scripts\start.py # WindowsPython 3.12+ is required. review_server.py uses backslashes inside f-string expressions,
which older interpreters reject at parse time — so on macOS's stock 3.9 it dies with a
SyntaxError before running a line, which reads as a broken repo rather than an old Python.
start.py checks first and says so. If someone reports "it will not start", ask for
python3 -V before anything else.
Humans double-click Start-reviewing.command (macOS) or Start-reviewing.bat (Windows) and
never need you for this — say so rather than offering to run it for them. See
DAILY-WORKFLOW.md, which is the page to point a contributor at.
Run the bare server only when you need it detached from a session or on a different port:
python3 scripts/review_server.py --port 7778 --no-browserEither way, confirm it answers before telling anyone it is up, and quote the port you
actually used. Loopback-only; nothing leaves the machine except avatar images (--no-avatars
turns even those off).
⚠ A harness-tracked background task gets reaped when the turn ends. Launch it detached
(nohup … & disown, then check ppid is 1) or it will be dead by the time they click the
link.
Only if a merge has landed since anyone last looked, which after any active day it will have:
python3 scripts/check_foundry_drift.pyCheap, read-only, and it catches the case nothing else does — main carrying content the
live agents have not received. Mention the result in your first response either way; "in
sync" is information too. If it reports drift, do NOT fix it silently: say what drifted and
in which direction, and ask, because an upload is a production change (hard rule 5).
End your first response with the URL, visually separated — not buried in a paragraph. Every admin and every contributor needs it, and it is the one thing that makes the review queue reachable. Something like:
────────────────────────────────────────────
Transcript review UI: http://127.0.0.1:7777
4 pending · 1 suggestion awaiting you
────────────────────────────────────────────
Include the counts from python3 scripts/review_status.py — "4 pending" tells someone
whether to open it now. If suggestions are waiting on them specifically
(--suggestions --for <user>), say so on that line: it is addressed to them and nobody else
will pick it up.
Verified live 2026-08-21. Everything lives in tenant Tyler Technologies, project OneTyler - Cloud Living Agents - v1.0.0.
| Sub-agent | Agent ID | KB collection | Local folder | Foundry upload? |
|---|---|---|---|---|
| Ops Center | 5b3efdff-921a-4131-be81-b7a4be427d9b |
OT-OpsCenter |
Knowledge-OpsCenter/ |
✅ yes |
| General Blueprint Docs Agent | bd1c5d91-8234-486e-9f5a-2f1b7a947426 |
OT-BPD |
Knowledge-BP-General/ |
✅ yes |
| Support Access Center | 55444576-1fa3-4d12-a738-6ba83b17e6a7 |
OT-SAC |
Knowledge-SupportAccessCenter/ |
✅ yes |
| Aligned Releases | b0544224-b120-469a-8f39-c4a7b14c17c0 |
OT-AlignedReleases |
Knowledge-AlignedReleases/ |
✅ yes |
All five collections are writable from this repo.
| Tyler Identity Assistant | 3f5e586f-0d0f-4638-9839-bebe45a6cb47 | TCP-KB-Identity | Knowledge-TylerIdentity/ | ✅ yes (since 2026-08-24) |
Team OneTyler Cloud Living: e92bd437-cb84-4e18-88e6-757370b39c90
Go-live: 2026-08-19 19:42:29 UTC. The merge of tyler-technologies/tcp-ops-center
PR #1206 ("Feat/cd 285/foundry chatbot"), which shipped the chatbot into Ops Center.
Every Foundry conversation before that instant is the team testing their own agents, not
user signal — see Acting on transcript reviews. Use the full timestamp, not the date: the
2026-08-19 21:28 UTC transcript is post-go-live while the merge was 19:42 the same day.
Base URL: https://foundry.tylertechai.com (override with $FOUNDRY_API_URL).
Re-verify IDs rather than trusting this table if anything 404s — agents can be recreated.
List agents with GET /api/transcripts/agents.
test -n "$FOUNDRY_API_KEY" && echo "key set (${#FOUNDRY_API_KEY} chars)" || echo "NOT SET"If unset, ask the user for it. Assume they know how to load an env var — the part worth being explicit about is where the key is kept, because the obvious answer is wrong here.
Ask which platform the user is on before giving instructions. The team runs both macOS and Windows, and the two have different credential stores and different cloud-sync traps.
This is the documented default as of 2026-08-26. No plaintext on disk, encrypted at rest, unlocked by the login session:
# store once — prompts for the value, so it never lands in shell history
security add-generic-password -a "$USER" -s foundry-api-key -U -w
# in ~/.zshrc
export FOUNDRY_API_KEY=$(security find-generic-password -a "$USER" -s foundry-api-key -w)Verify with the request in step 3 below rather than assuming the export worked. A shell that was already open will not have it — the user needs a fresh terminal.
Caveats, so this is not oversold: the first access from a new process may prompt for keychain permission, and a cron or otherwise non-interactive run needs the keychain unlocked. If that bites, the file fallback below is legitimate.
The closest equivalent, with no modules to install. ConvertFrom-SecureString encrypts with
DPAPI keyed to that user on that machine, so the file is useless if copied anywhere else —
including if OneDrive syncs it:
# store once — prompts, so the value never lands in PowerShell history
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\Foundry" | Out-Null
Read-Host -AsSecureString "Foundry API key" | ConvertFrom-SecureString |
Set-Content "$env:LOCALAPPDATA\Foundry\key.dpapi"
# add to your PowerShell profile ($PROFILE)
$sec = Get-Content "$env:LOCALAPPDATA\Foundry\key.dpapi" | ConvertTo-SecureString
$env:FOUNDRY_API_KEY = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))%LOCALAPPDATA% is deliberate: unlike %USERPROFILE%\Documents or %OneDrive%, it is not
roamed or cloud-synced.
If they would rather use PowerShell SecretManagement (Install-Module Microsoft.PowerShell.SecretStore),
that is also fine and arguably nicer — it just needs a module install, which some machines
restrict.
A plain user environment variable is NOT equivalent. setx FOUNDRY_API_KEY ... stores the
key as cleartext in the registry under HKCU\Environment, readable by any process running as
that user and visible in setx history. Use it only if the options above are unavailable, and
say plainly that it is weaker.
The Python scripts are stdlib-only and run as-is with python instead of python3.
scripts/start_review_session.sh is bash — it works under Git Bash (ships with Git for
Windows) or WSL, but not in PowerShell or cmd. If a contributor is stuck, the equivalent is
git switch main && git pull --ff-only, then python scripts/fetch_transcripts.py and
python scripts/review_status.py.
-
Outside this repo.
.gitignoreblocks*.envandfoundry-secrets*, but that is a backstop; being outside the working tree is the actual control. -
Outside any cloud-synced folder — OneDrive, Dropbox, iCloud Drive, Google Drive. This is the one that catches people, including this repo's own instructions until 2026-08-26: they said "one directory above this checkout", and for a checkout under
~/Library/CloudStorage/OneDrive-.../Repo/...that put a live production key straight into OneDrive, replicated off the laptop and onto every device on the account. A path can satisfy rule 1 and still be badly wrong.On Windows the same trap is
C:\Users\<you>\OneDrive - Tyler Technologies, Inc\..., and note thatDocumentsandDesktopare often redirected into OneDrive by policy — so those are cloud-synced even though the path does not say so.%LOCALAPPDATA%is not. -
chmod 600. It is a tenant-scoped production credential that can rewrite what live agents tell customers.
Safe locations that satisfy all three, wherever the repo is checked out:
~/.config/foundry/secrets.env (chmod 700 the directory) on macOS/Linux, and
%LOCALAPPDATA%\Foundry\ on Windows.
Check, do not assume. If the user already has a key file, test the path rather than trust it:
f=<their key file>
ls -l "$f" # want -rw------- (600)
case "$(cd "$(dirname "$f")" && pwd -P)" in
*CloudStorage*|*Dropbox*|*"Google Drive"*|*"Mobile Documents"*)
echo "CLOUD-SYNCED — this key is leaving the machine; tell the user" ;;
*) echo "not cloud-synced" ;;
esacA key that has been in a cloud-synced folder, a chat window, or a log should be rotated, not just relocated. Deleting the file does not purge OneDrive version history.
Never write the key into a file in this repo, a script, a command that gets committed, or your own response. Never ask the user to paste it to you — point them at Dev → API Keys.
Every person uses their own key. Keys are per-user, so a shared one would make every action look like one person's in the audit trail and rotating it would break everyone at once. Keys are tenant-scoped (a key from another tenant returns 403 with no useful message) and each user may hold 10. A 401 means the key was rotated or revoked — ask for a fresh one.
Note what a contributor's key can do: uploading changes what live agents tell users, with no
review gate of its own. The only thing keeping that honest is hard rule 5 — upload only what
is already merged to main, and run preflight_upload.py to prove it.
-H "X-API-Key: $FOUNDRY_API_KEY" # NOT "Authorization: Bearer" — that form is deprecated
-A "claude-code-foundry-kb/1.0" # any User-Agent; a request with none gets a 403 from the WAFThe missing-User-Agent 403 is the single most confusing failure mode: the key is valid, the
URL is right, and it still fails. Always pass -A.
curl -s -A "claude-code-foundry-kb/1.0" -H "X-API-Key: $FOUNDRY_API_KEY" \
"https://foundry.tylertechai.com/api/transcripts/agents" \
-o /tmp/agents.json -w "HTTP %{http_code}\n"HTTP 200 and a JSON array means you are good. HTML back means auth failed and you were redirected to a login page.
No — use the REST API. There is no MCP server for managing Foundry, and none is
configured in this project. Do not go looking for mcp__foundry__* tools.
Two things are easy to confuse here:
- MCP inside Foundry — Foundry agents can call external MCP tool servers you register in Dev Studio → MCP Registry. That is a runtime capability of the agents, unrelated to managing knowledge bases or reading transcripts.
mcp__tcp-mcp__*tools — if present in your session, these query the Tyler Cloud Platform database (orgs, workspaces, products, profiles, audit logs). Useful for fact-checking knowledge-file content, but they have nothing to do with Foundry.
For the full API surface beyond what's documented here, Foundry serves an OpenAPI 3.1 spec
plus Swagger UI at /api/swagger-ui, and there is an API Explorer at /dev/api-explorer.
There is no Python or .NET SDK — the only client SDK is TypeScript (@ai-foundry/sdk),
and it is for embedding chat widgets, not for this work. Call REST directly.
There are two, and the obvious one is the wrong one:
- ❌
/api/chat/conversations— scoped to your own conversations only. Useless for corpus analysis, despite being the documented one. - ✅
/api/transcripts/*— admin-scoped, sees all conversations you have rights to. Use this. It is not documented on Confluence.
| Purpose | Endpoint |
|---|---|
| List agents | GET /api/transcripts/agents |
| List teams | GET /api/transcripts/teams |
| Find agent conversations | GET /api/transcripts/conversation_ids |
| Find team conversations | GET /api/transcripts/team_conversation_ids |
| Full agent transcript | GET /api/transcripts/{conversationId} |
| Full team transcript | GET /api/transcripts/team/{conversationId} |
| Aggregate stats | GET /api/analytics/stats |
| Run-level logs (tokens, model, errors) | GET /api/agent-logs?agentId=… |
| OpenTelemetry spans for a run | GET /api/agent-logs/spans?runId=… |
conversation_ids parameters: agent_id, feedback (positive/negative),
conversation_type=conversation_with_feedback (any feedback), search (free text, max 500
chars), startDate / endDate (MM/DD/YYYY, YYYY-MM-DD, or ISO 8601).
team_conversation_ids takes the same plus team_id, and includes feedbackType inline.
UA="claude-code-foundry-kb/1.0"; B="https://foundry.tylertechai.com"
AGENT="5b3efdff-921a-4131-be81-b7a4be427d9b" # Ops Center
# list
curl -s -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
"$B/api/transcripts/conversation_ids?agent_id=$AGENT&startDate=01/01/2026" \
| python3 -m json.tool
# fetch one (returns a single-element array)
curl -s -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
"$B/api/transcripts/<CONVERSATION_ID>" | python3 -m json.tool[{ "conversationId": "...",
"conversation": [
{ "question": "...", "response": "...",
"feedback": "THUMBS_UP | THUMBS_DOWN | NO_ACTION",
"thumbsDownTextFeedback": "user's written complaint",
"metadataMessages": [ ... full message stream incl. toolCalls and tool results ... ] }]}]
metadataMessages is the whole point. It shows what the agent actually did. Use it to
tell apart four failures that look identical in the visible chat:
What you see in metadataMessages |
Diagnosis | Fix belongs in |
|---|---|---|
| No tool calls at all | Answered from model priors | Agent prompt |
searchTenantKnowledge returned nothing |
Content missing or unretrievable | Knowledge file |
| Searched, got irrelevant chunks | Wrong content or bad chunking | Knowledge file |
| Got the right chunks, answered badly | Reasoning/prompt problem | Agent prompt |
Only the middle two are knowledge-file problems. Do not rewrite a knowledge file to fix a prompt bug.
- 200-result cap, newest first. Exactly 200 back means you are truncated — narrow the date window and page through.
- Default lookback is 3 months if you omit
startDate. /api/analytics/feedbackand/api/analytics/feedback-trendsare unreliable (they read a different table). Use/api/transcripts/*for feedback and/api/analytics/statsfor totals.- There is very little feedback on these agents (as of 2026-08-21: one rated conversation across the whole team). Expect to analyze raw conversations rather than filtering on thumbs-down.
- Write dumps outside the repo (
/tmp/...), never into it. See Hard Rule 3.
Re-uploading the same filename replaces it cleanly, in place. Verified 2026-08-22 on
OT-OpsCenter: the response returns the same id with wasUpdated: true, the new
fileSize, and ingestionStatus reset to pending. The collection file count does not
change and no duplicates are created. You do not need to delete the old record first.
The filename is the identity key. So a rename is not an update — uploading
NewName.md adds a second record and leaves OldName.md in place. Renaming therefore
requires upload-new → DELETE old → verify count.
Always pass an explicit mime type: -F "files=@X.md;type=text/markdown". Without it curl
sends application/octet-stream, which lands in the file record and makes the collection
inconsistent with files uploaded through the UI.
| Purpose | Endpoint |
|---|---|
| List collections | GET /api/tenant-knowledge-base/collections |
| Create collection | POST /api/tenant-knowledge-base/collections {name} |
| List files + status | GET /api/tenant-knowledge-base/collections/{name}/files |
| Download a file (undocumented) | GET /api/tenant-knowledge-base/collections/{name}/files/{fileId}/download |
| Upload files | POST /api/tenant-knowledge-base/collections/{name}/files (multipart, field files) |
| Delete a file | DELETE /api/tenant-knowledge-base/collections/{name}/files/{fileId} |
| Start ingestion | POST /api/tenant-knowledge-base/sync → {jobId} |
| Poll ingestion | GET /api/tenant-knowledge-base/sync/{jobId} |
| Refresh statuses (read-only) | POST /api/tenant-knowledge-base/reconcile-statuses |
| Retrieval probe | POST /api/tenant-knowledge-base/retrieve |
Upload limits: 10 files per request, 100 MB per file. Allowed extensions include md,
txt, pdf, docx, csv, xlsx, png, jpg.
fileSize on the file record is your drift signal (there is no content hash). Compare
against local bytes:
UA="claude-code-foundry-kb/1.0"; B="https://foundry.tylertechai.com"
COL="OT-OpsCenter"; DIR="Knowledge-OpsCenter"
curl -s -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
"$B/api/tenant-knowledge-base/collections/$COL/files" -o /tmp/kbf.json
python3 - "$DIR" <<'PY'
import json, os, sys
for f in sorted(json.load(open('/tmp/kbf.json')), key=lambda x: x['fileName']):
p = os.path.join(sys.argv[1], f['fileName'])
if os.path.exists(p) and os.path.getsize(p) != f['fileSize']:
print(f"DRIFT {f['fileName']:46} kb={f['fileSize']:>7} local={os.path.getsize(p):>7}")
PYSizes matching is strong but not conclusive evidence of identical content. If it matters,
download the remote copy with the download endpoint above and diff it.
This is the biggest trap. Bedrock runs one ingestion job at a time per data source, and
a job only indexes files present in S3 when its scan snapshot is taken. Every upload request
auto-triggers its own scoped sync. So a naive 30-file upload becomes 3 queued jobs, the
first indexes only its 10 files, and the rest sit pending for hours.
Correct order: upload every batch first → then trigger ONE consolidated sync → poll to
terminal → re-sync until everything reports ingested.
UA="claude-code-foundry-kb/1.0"; B="https://foundry.tylertechai.com"; COL="OT-OpsCenter"
# upload (repeat -F per file, max 10 per request; ALWAYS set the mime type)
curl -s -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
-F "files=@Knowledge-OpsCenter/Misc-Links.md;type=text/markdown" \
-F "files=@Knowledge-OpsCenter/_START_HERE.md;type=text/markdown" \
"$B/api/tenant-knowledge-base/collections/$COL/files"
# ...all other batches...
# ONE consolidated sync — Content-Type header is REQUIRED even with no body
curl -s -X POST -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
-H "Content-Type: application/json" \
"$B/api/tenant-knowledge-base/sync" # -> {"jobId":"...","status":"STARTING"}
# poll (jobs on a large shared tenant can take a long time)
curl -s -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" \
"$B/api/tenant-knowledge-base/sync/<JOB_ID>"Omitting Content-Type: application/json on POST /sync returns
{"error":"Content-Type must be application/json"} — easy to mistake for a transient
failure and retry pointlessly. POST /sync also returns genuine intermittent 500s
({"error":"Failed to start sync"}) on Bedrock job conflicts; retry those every ~45s.
If you uploaded a single batch (≤10 files), you probably don't need a manual sync at
all — the upload's own auto-sync picks it up, and the files will already be ingesting
by the time you look. Check statuses before starting another job; launching a second sync
while one is running just queues behind it.
The sync job covers the whole tenant data source, so numberOfDocumentsScanned and
numberOfDocumentsFailed include other teams' files. Judge your collection by its own file
statuses and retrieval probes, never the global counters.
ingestionStatus: "ingested" proves nothing. A file can report ingested and hold zero
retrievable text. Markdown avoids the worst of this (the classic victims are scanned or
encrypted PDFs), but always confirm:
curl -s -X POST -A "claude-code-foundry-kb/1.0" -H "X-API-Key: $FOUNDRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"<question the file should answer>","numberOfResults":5,
"searchType":"HYBRID","filterCollectionNames":["OT-OpsCenter"]}' \
"https://foundry.tylertechai.com/api/tenant-knowledge-base/retrieve" \
| python3 -c "
import json,sys
for r in json.load(sys.stdin):
uri=str(r.get('metadata',{}).get('x-amz-bedrock-kb-source-uri','?')).split('/')[-1]
c=r.get('content') or ''
print(round(r.get('score',0),3), 'len=%d'%len(c), uri[:60])"Always pass filterCollectionNames. An unfiltered retrieve searches the entire shared
tenant KB — other teams' collections included — which both pollutes your results and
misrepresents what the agent sees (the agent's own searchTenantKnowledge is scoped to the
collections in its config).
Red flags: zero results for content that should match, or results with len=0 (the file
indexed but produced no text).
Retrieval can go live before ingestionStatus flips. Measured 2026-08-22 on a 6-file
replace in OT-OpsCenter: retrieval was already serving the new chunks (and had purged the
superseded ones) within ~100 seconds, while the status field stayed ingesting until
~6 minutes after upload. So a content probe confirms a change sooner and more reliably
than the status field — but expect minutes, not hours. If files are still ingesting
after ~30 minutes, treat that as a real problem worth investigating, not as normal lag.
To confirm, probe for a string that exists only in the new version, and separately confirm a string unique to the old version returns nothing:
# did the new text land, and is the old text gone?
... | python3 -c "
import json,sys
rs=json.load(sys.stdin)
print('new:', sum('<STRING ONLY IN NEW VERSION>' in (r.get('content') or '') for r in rs))
print('old:', sum('<STRING ONLY IN OLD VERSION>' in (r.get('content') or '') for r in rs))"new>0 and old==0 means the reindex is done regardless of what the status field says.
Note that re-sync cannot repair an already-indexed file — Bedrock skips what it considers indexed. Delete and re-upload instead.
End any response that edits knowledge files with an explicit, visually separated list of the files to re-upload and which collection they belong to. Do not bury it in prose.
Because there is no update-in-place, renaming a file locally and pushing creates a second file in the collection rather than renaming the existing one. To rename properly:
- Upload under the new name.
DELETEthe old file record by itsfileId.- Sync once.
- Verify the collection has exactly one copy.
This was applied to Knowledge-TylerIdentity/Docusaurus-Identity.md on 2026-08-24: uploaded
under the new name, tyler-identity-knowledge-base.md deleted from TCP-KB-Identity, count
verified. That collection is now fully in sync with this repo.
Note the asymmetry: re-uploading the same name is a clean in-place replace (see "The model you must internalize"), so ordinary content updates need no deletion. Only renames do.
APPROVE & MERGE NOW DOES THIS. Do not perform the steps below by hand unless the button
cannot. As of 2026-09-16 a merged change to team-config/team-routing-prompt.md is written
to the live team object by the merge itself (publish_router_after_merge in
scripts/review_server.py), running the whole chain: merged-first check, angle-bracket refusal,
already-live no-op, committed-backup check, native version snapshot, full-object PUT, re-read,
and a field-by-field diff that fails loudly if anything but system_prompt moved.
Your job when changing routing is therefore:
- Edit the fenced block under
## Currentinteam-config/team-routing-prompt.md. That block is the live prompt — the file is a 263-line document whose change log quotes older prompts and contains the literal text<product>and->, so only the first```textblock under## Currentis published. Verified byte-identical to the livesystem_prompton 2026-09-16 (8,465 chars). - Commit the pre-change team object in the same request —
team-config/backups/team-backup-<YYYYMMDD-HHMMSS>.json, the full GET response verbatim, scanned for credentials. Without it the merge refuses to publish the router and says so on the card before it is clicked. It is also the tripwire for a UI edit: if the live prompt no longer matches it, the prompt in the request was written against a baseline that is gone. - Keep
<and>out of the prompt entirely (see step 4 below for why). - After the merge reports it live, run the behavioural test — the merge verifies the TEXT landed, which is not the same as routing changing. Ask the question that was misrouted, plus a control that must still go elsewhere.
Everything below is the manual fallback, for when the button cannot run it — no key in the server's environment, an unparseable mirror, or a restore. It is the same sequence the button performs. Verified end to end on 2026-08-27.
Who: repo admins only. team-config/ is admin-only (hard rule 6), and this is a
production config change (hard rule 5), so confirm the change with the user first and never
run the manual PUT as a side effect of another task. If you are running for a contributor, stop
and say the change needs an admin.
Note where the confirmation now sits for the automated path: Approve & Merge is the confirmation. An admin clicking it, through a gate that names the routing change explicitly, is the decision to ship — which is not the same as an agent PUTting a config object mid-task. Writing the prompt into the mirror and opening a request is still reviewable, still not live, and still needs the user's say-so before you open it.
Why editing the mirror is not itself a fix: team-config/team-routing-prompt.md changes
nothing at runtime on its own — until the live prompt changes, routing behaves exactly as
before. What makes it live is the merge, not the edit.
T=e92bd437-cb84-4e18-88e6-757370b39c90 # OneTyler Cloud Living
UA="claude-code-foundry-kb/1.0"; B="https://foundry.tylertechai.com"1. Native version snapshot — a real restore point.
curl -s -X POST -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" -H "Content-Type: application/json" \
-d '{"type":"full","name":"pre-<what-you-are-changing>-YYYYMMDD"}' "$B/api/teams/$T/versions"type is required and must be "full" or "draft" — undocumented in the OpenAPI spec;
omitting it returns a 400 Zod error that names the allowed values. Foundry also auto-creates a
version when someone saves in the UI, so there is usually a recent one already.
Restore path if anything goes wrong:
POST /api/teams/{teamId}/versions/{versionId}/restore.
2. Back up the object to the repo as well, and commit it (hard rule 8) —
team-config/backups/team-backup-<YYYYMMDD-HHMMSS>.json. Scan it for credentials first.
3. Fetch the CURRENT live object and edit only system_prompt. Never build the payload
from an older backup: someone may have edited in the UI since. Assert your find-target appears
exactly once before replacing it.
4. No angle brackets in prompt text. Foundry HTML-escapes > and strips <tag>-shaped
text, so a prompt containing them comes back altered. Use hyphens for dashes.
5. PUT the FULL object back.
curl -s -X PUT -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" -H "Content-Type: application/json" \
--data @payload.json "$B/api/teams/$T"The spec documents no request body for this endpoint, so the semantics are not knowable
from the docs. Measured: it is a full replace, and sending the whole object back loses
nothing. Sending only {"system_prompt": ...} risks wiping agent_ids, orchestrator_config,
routing_rules and chatExperience — which would take the team down. Send everything.
Two corrections to what this used to say, both from 2026-09-16:
- The object has 22 top-level fields, not 18 (
agent_ids,chatExperience,created_at,description,id,implementation_controls,is_active,metadata,name,orchestrator_config,origin,package_generation_digest,package_graph_role,package_lineage_id,package_root_team_id,project_id,routing_rules,source_id,system_prompt,tenant_id,updated_at,user_id). Don't assert a count; fetch and count. - Whether the body is the bare object or
{"team": {...}}is not recorded anywhere. This file says "the full object" whileteam-config/README.mdsays a backup holds "the full GET response verbatim" — the wrapped form — and "to restore: PUT the backup body back". Nobody wrote down which one the successful 2026-08-27 write used.publish_router_after_mergetherefore tries bare, and retries once wrapped on a 400/415/422 only — a rejection means nothing was written, so the retry cannot double-apply. It reports which shape was accepted; when you next see that line, record the answer here.
6. Verify — three checks, all of them.
system_promptmatches your intended text exactly (not just "longer than before"), and contains no>/</&.- Field-by-field diff against the pre-PUT backup. Only
system_promptandupdated_atmay differ. This is the check that catches a bad payload contract. - A behavioural test, plus a control. Text landing is not the same as routing changing.
curl -s -N -X POST -A "$UA" -H "X-API-Key: $FOUNDRY_API_KEY" -H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"<the question that was misrouted>"}]}' \
"$B/api/team/$T/stream"Note the payload shape: messages array, not message — the singular form returns
400 Messages array is required. The response is SSE text-delta chunks, so reassemble the
deltas before matching on content; grepping the raw stream fails because words are split
across chunks. Read which collection the answer cites to see where it routed.
Always run a control question that must still route somewhere else, or you have only shown you can pull everything toward one agent.
7. Update team-config/team-routing-prompt.md to match, so
scripts/check_foundry_drift.py reports the router in sync. Update README.md too if the
team-level routing rules changed.
Most of the indexed knowledge is derived from Blueprint (docs.tylerdev.io). So a
Docusaurus- knowledge file fixed on its own is not fixed — it is fixed until the next
reconciliation re-derives it from a Blueprint page that still says the old thing. When a review
is marked BP updates, the Blueprint edit is what makes the knowledge fix stick.
This section applies on every machine, contributor or admin. A contributor's GitHub token can push to Blueprint and open a change request there; only an admin can approve it. That split is why every request is opened in auto-merge mode — see below.
The flow expects a clone of tyler-technologies/corpdev-new-blueprint as a sibling of this
repo's parent:
<some dir>/
foundry/onetyler-foundry-team-agent-kb/ <- this repo
blueprint/corpdev-new-blueprint/ <- the Blueprint clone
Override with BLUEPRINT_REPO if it lives elsewhere:
export BLUEPRINT_REPO=/path/to/corpdev-new-blueprint # macOS/Linux
$env:BLUEPRINT_REPO = "C:\path\to\corpdev-new-blueprint" # Windows PowerShellIf it is missing, the review UI says so rather than failing quietly — but the Blueprint half of a batch cannot be done at all until it exists.
git -C "$BLUEPRINT_REPO" switch master
git -C "$BLUEPRINT_REPO" pull --ff-onlyThis is a correctness requirement, not hygiene. Blueprint edits are captured as a diff
against origin/master. Edit a stale tree and that diff also contains the reverse of
every upstream commit your tree is missing — attributed to your transcript and opened as part
of its change request. Merging it would revert other people's work.
Measured on a tree exactly one commit behind: the captured patch picked up
src/clientModules/chatbot.js, a file the transcript never touched, carrying 38 removal
lines. Nothing downstream can catch this — the patch applies cleanly and every hunk in it is a
real diff; it is only wrong about whose change it is.
So scripts/bp_stage.py refuses unless the checkout's HEAD is exactly origin/master.
Syncing first is not advice; it is the only way it will let you proceed.
The review UI also syncs both repos itself at the start of the check and the send, fast-forward only. It never forces over local work, so a refusal there is reported and is usually just "you have work in progress".
Each transcript marked BP updates gets its own Blueprint request, so the attribution has to be explicit — a working tree is one flat pile of edits and cannot say which transcript asked for what. Work one transcript at a time:
# ... make the Blueprint edits for ONE transcript ...
python3 scripts/bp_stage.py --transcript transcripts/<agent>/<file>.md
# ... the Blueprint tree is now clean again; do the next transcript ...
python3 scripts/bp_stage.py --list # who owns what, and what is missing
python3 scripts/bp_stage.py --drop <rel> # withdraw one transcript's Blueprint changesStaging captures everything Blueprint has against master right now and then resets that
checkout. That reset is what keeps the next capture from re-including this one — so staging
between transcripts is load-bearing, not tidiness.
Patches live in .bp-stage/ (gitignored: it is another repo's working state). A transcript
marked BP updates with nothing staged is refused at send time, and Part 2 shows it in red
before you get there.
Do not commit or push in the Blueprint checkout yourself. The requests are opened per transcript, after the eval is approved — never before, because an unevaluated change is one nobody has checked.
Every request this flow opens — Blueprint and knowledge alike — is put into auto-merge
(gh pr merge --auto) at creation. A contributor opens it and cannot approve it; an admin
approves; auto-merge joins those two without anyone having to come back at the right moment.
Without it a request sits green and unmerged, and the knowledge file ships while the Blueprint
page it was derived from does not — which the next reconciliation then reverts.
If enabling auto-merge fails for want of permissions, the output says so plainly and an admin turns it on. The request itself is correct either way; do not re-open it.
Both markers are applied at creation, and the History section on the PRs page accepts either:
| Marker | Knowledge repo | Blueprint |
|---|---|---|
| Branch prefix | review/ |
kb-review/ |
| Label | onetyler-review |
onetyler-review |
The label makes the set identifiable on GitHub itself, which is what Blueprint needs — it is a
shared docs repo with plenty of unrelated traffic, and "every open request" is not the answer
there. The branch prefix covers the back-catalogue, since the label only exists from 2026-08-29;
headRefName survives the branch being deleted at merge, so it stays readable indefinitely.
Labelling may need more rights than a contributor's token has, particularly in Blueprint. That is reported and not fatal — the prefix keeps the request in the history regardless, so do not re-open a request that merely failed to get its label.
transcripts/ holds preserved conversation history, one markdown file per conversation,
with review fields in the frontmatter. Humans review; you act on what they wrote. Full
workflow and field definitions: transcripts/README.md.
Humans review through a local web UI (python3 scripts/review_server.py, loopback-only on
port 7777) or by editing the markdown directly. Either way the output is the same files.
The lifecycle is pending → suggested → reviewed → pushed (plus excluded for
pre-go-live testing). reviewed is your inbox. pushed means processed and live in
Foundry — it is a claim about Foundry, not the repo, so only set it after the upload is
verified. Close out with python3 scripts/mark_pushed.py, which refuses to close a transcript
whose kb_action is still unresolved.
suggested is not your inbox. It is a reviewer's worked-up opinion on an area they do not
own, handed to the owner named in awaiting, with suggested_by recording who wrote it and
reviewer deliberately blank. The fields look exactly like a finished verdict, which is the
trap: acting on one applies a change nobody approved and takes the decision away from the
person the state exists to reserve it for. --actions lists these separately as "not
actionable yet" and mark_pushed.py refuses them; do not work around either. If a suggestion
looks obviously right and is blocking, say so and ask the owner to accept it — never accept it
on their behalf, and never put a human's name in reviewer yourself.
Your half of the process is steps (e)–(g): process the reviewed ones, update knowledge
files if needed, open a PR (do not push content changes straight to main — the admin
exemption exists so the owner isn't blocked, not so you can skip review), then upload to
Foundry and mark the transcripts pushed. Full seven-step process: transcripts/README.md. A new human reviewer is onboarded via transcripts/ONBOARDING.md — keep it accurate when the tooling or process changes, since it is the first thing they read.
To find work:
python3 scripts/review_status.py --actions # reviewed items with an open KB action
python3 scripts/review_status.py # dashboard + regenerate INDEX.mdReview collisions are a real failure mode. Two people can first-review the same
transcript and both diffs apply cleanly, so the second merge silently overwrites the first
verdict. scripts/validate_reviews.py runs in CI and fails a PR that re-marks something
already reviewed on the base branch unless review_round is raised. Never resolve that by
lowering the round or reverting the other person's verdict — pull the base branch, read it,
and re-review explicitly if you still disagree.
READ ALL THE FEEDBACK AS ONE BODY BEFORE CHANGING ANYTHING. Do not walk the reviewed transcripts one at a time, fixing each as you go. Read every piece of feedback in the batch first, then decide what to change.
The reason is that the same underlying problem shows up in several transcripts wearing different clothes, and one-at-a-time processing produces three narrow patches instead of one correct fix. A worked example, from the 2026-08-27 batch: three separate transcripts — org-admin access, internal org creation, and an Admin Center login error — each carried a note about the wrong ticket link. Treated individually they look like three content gaps. Read together they are one missing rule about which link to hand out, fixed once.
The reverse trap is just as real: two transcripts can look like the same question and need different fixes. In that same batch, "I need to be added as an org admin" and "add org admin to org" are the same question, but one got a good answer needing refinement and the other got a clarifying question instead of an answer — a content refinement versus a retrieval failure.
So, in order:
- Read every reviewed transcript's prose in full, plus the questions and the answers given.
- Group the feedback by underlying cause, not by transcript.
- Check what is already fixed. Some feedback predates a change you already shipped.
Compare the transcript's
date:against when the relevant file was last uploaded, and test the question live before writing anything. Do not re-fix something that works. - Decide the smallest set of changes that covers the whole batch, then apply them.
- Report per-transcript so the reviewer can follow their own feedback through, even where several transcripts resolved to one change.
contributor-initial-prompt.md AND contributor-update-prompt.md CONTAIN NOTHING BUT THE
PROMPT. No title, no headings, no copy-paste markers, no explanation, no "what should happen"
section. Every explanatory word belongs in contributor-prompting-guide.md instead.
The reason is observed, not theoretical: the first contributor copied an entire prompt file — surrounding commentary included — and pasted the lot. No damage, but it showed that asking someone to select the right block between markers is a design flaw, not a user error. Now the whole file IS the block, so copying everything is the correct action and there is no wrong part to pick.
So if you find yourself adding a heading or a note to either file, stop — put it in the guide and link to it. Anything you add to those files is text a human will paste to an agent as if you had addressed it to them.
WHEN YOU REFER TO A TRANSCRIPT, GIVE A LOCATOR THE HUMAN CAN OPEN. Never identify one by
its hash alone. 75043484 means nothing to the person who wrote the feedback — they reviewed
it in a browser, not in a filename.
Every time you mention a transcript in a question or a report, include:
http://127.0.0.1:7777/t/<agent>/<YYYY-MM-DD>--<hash>.md
plus the question the user actually asked, quoted, and the date. The question text is what they will recognise; the URL is what lets them check you. For example:
http://127.0.0.1:7777/t/team/2026-08-26--75043484.md — 2026-08-26 16:23, team agent, asked:
add org admin to org
Use the repo-relative path as well when the point is about the file rather than the
conversation (transcripts/team/2026-08-26--75043484.md), and quote the reviewer's own note
back when you are asking what they meant by it — they wrote it hours or days ago.
If the review server is not running, start it, or say plainly that the URL will not resolve until it is. A link that 404s is worse than a path.
This came from getting it wrong: a question referring to "75043484" was unanswerable because nobody could find what it pointed at.
ASK WHEN FEEDBACK IS AMBIGUOUS. NEVER ASSUME. If a piece of feedback could reasonably mean two different things, stop and ask which. Do not pick the more likely reading and proceed.
This costs a round-trip and saves a wrong change to a live agent. The asymmetry is the whole argument: a question costs minutes, while a misread ideal response ships wrong content to customers, looks resolved on the dashboard, and is only caught if someone re-reads the transcript later.
Ambiguity worth asking about, in practice:
- Where a fix belongs when the feedback describes a behaviour rather than a fact — a knowledge file, an agent's system prompt, or the team router. These have different owners and different blast radii, and only the knowledge file is yours to change from here.
- Which transcript or file a reference points to, when the feedback says "the same as the other one" or "that page" and more than one candidate exists.
- How far to go — whether "improve the answer" means editing existing content or writing a new entry, and whether it should apply to one agent or all of them.
- Anything requiring the user's own action, such as a Foundry UI edit. Never assume they will do it; confirm they want to.
Ask one question at a time, not a bundled list, and do all the unambiguous work first so the question is not blocking progress. State plainly what you have already done, what you are blocked on, and what you would do under each reading.
THE PROSE IS THE FEEDBACK. THE FIELDS ARE A HINT, AND OFTEN A WRONG ONE.
Reviewers write the ideal response under the bad answer, and/or a Proposed fix, and click
Mark reviewed. They frequently do not touch the dropdowns — and they are not expected to.
Writing "this is wrong, it should have said X" is the valuable part; turning that into
diagnosis and fix_target is clerical work, and it is your job.
This matters because the form opens pre-filled as "nothing wrong" (routing correct,
answer good, diagnosis n-a, fix_target: none, kb_action: none,
action_status: none-needed), so a transcript can assert nothing is wrong in its frontmatter
while its body says the answer was wrong. Measured on 2026-08-26 in exactly that state:
--check passed and --actions printed nothing while the dashboard said work was
waiting. An agent followed that pointer, found an empty list, and would have concluded there
was nothing to do.
So:
- Read the whole transcript body — every
<!-- review:N -->block and the<!-- proposed-fix -->block. Never decide there is no work from the fields alone. --actionsnow reports these under "reviewed transcript(s) with WRITTEN feedback and no classification". Treat that list as the real inbox.- You may — and should — fill in the classification fields from the prose:
routing_verdict,reassign_to,answer_verdict,diagnosis,fix_target,kb_action,kb_files,action_status. Say in your response which values you derived and from which sentence, so a human can check your reading. - Never set or change
review_statusorreviewer. Those record a human's judgement and their identity. The human has already reviewed it; you are classifying, not deciding. Do not "upgrade" asuggestedtranscript toreviewedeither. - If the prose is genuinely ambiguous, ask rather than picking a
diagnosis— the wrong diagnosis sends the fix to the wrong place, which is worse than a delay. - Clear the
needs-triagemarker fromnotesonce you have classified it, and record what you concluded.
Only act on files with review_status: reviewed. A pending file has not been looked
at by a human, and a suggested one has been looked at by someone who explicitly declined to
decide; do not infer corpus changes from either unprompted.
The reviewer, suggested_by, and awaiting fields are all constrained to the github
values in contributors.json, which is
a generated file — python3 scripts/sync_contributors.py rebuilds it from the
onetyler-tcp-pm-admins and onetyler-tcp-pm-contributors GitHub teams. Never hand-edit it:
an entry added by hand is overwritten on the next sync and confers no repo access anyway. To
add a reviewer, add them to the contributors team, re-run the sync, and commit.
--check exits 1 when the file has drifted from team membership.
Never invent a reviewer name, and never set review_status: reviewed yourself — that field
records a human's judgement. python3 scripts/review_status.py --check enforces both
(unknown reviewer, or reviewed-with-no-reviewer, exits 1); run it before committing review
changes.
For each open action:
- Read the transcript — the
diagnosisfield, and the reviewer's "should have said" text in the<!-- review:N -->block. - Check
fix_targetfirst. It states where the reviewer decided the fix belongs:knowledge-file,agent-instructions,team-routing,sample-prompts, ornone. Onlyknowledge-filemeans you edit a corpus file. For the others, the deliverable is a concrete proposal, not a corpus edit — read the## Proposed fixblock and either apply it where you can (the team routing table inREADME.md, a_START_HERE.mdhand-off rule) or surface it to the user for the parts you cannot change from here, such as an agent's system prompt or its sample questions, which live in Foundry rather than in this repo. - Respect the diagnosis. It encodes whether this is a knowledge problem at all:
search-emptyandsearch-irrelevantare corpus problems;no-searchandretrieved-ok-answered-badlyare agent-prompt problems, androuting-onlyis a team routing-rules problem. Do not edit a knowledge file to paper over a prompt bug — say so and leavekb_action: none. - Make the edit in the file(s) named by
kb_files, following the conventions below. - Update that folder's
_START_HERE.mdif you added, renamed, or removed a file. - Set
action_status: appliedin the transcript frontmatter. - Re-run
review_status.pyto refreshINDEX.md. - List the files needing re-upload to Foundry, and ask before pushing (Hard Rule 5).
If a reviewer set reassign_to, the fix is usually in the team routing table in
README.md, or in the sibling-agent hand-off guidance inside the relevant
_START_HERE.md — not in the answer content. Repeated reassignments to the same target
are the strongest signal the routing rules need work.
Pre-go-live transcripts are not feedback. Anything before 2026-08-19 19:42:29 UTC
(see Constants) is internal testing. Mark it review_status: excluded — not
reviewed, which would inflate the reviewed percentage with work nobody did — with
fix_target: none, kb_action: none, action_status: wontfix, and a notes line citing
the cutoff. An exclusion still needs a reviewer, because it is a judgement. 34 of the
first 41 transcripts fall before go-live; only 7 are in scope, all team or identity.
Do not re-litigate the cutoff: it was settled against PR #1206's merge time, and the
commit linked from that PR (a3be96ca) is only a merge-from-main dated Aug 11.
No shared PAT — by decision. contributors.json drift is checked locally, using each
member's own gh credentials, not in CI. GITHUB_TOKEN cannot read org team membership, and
storing a PAT as a repo secret would be reachable by any write-access contributor's PR — a
same-repo-branch PR receives repo secrets, unlike a fork PR. So the check runs in
scripts/start_review_session.sh instead. Do not add a shared token for this. If drift
automation is ever wanted, use a schedule-triggered workflow: it always runs the
default-branch version, so a PR cannot alter what executes.
Pulling new transcripts: python3 scripts/fetch_transcripts.py. It never overwrites an
existing file, so review edits are safe. It also drops canned starting-prompt exchanges
(chatExperience.sampleQuestions, hardcoded in the script) — re-check that list if the
agents' chat experience is reconfigured, or the filter will silently stop working.
Knowledge-Shared/Conf-OneTylerTickets.md answers every "which ticket do I file" question
for every agent. It is reconciled from three upstream sources — all of them, every time,
because each covers something the others do not:
| Source | Covers | Precedence | How to fetch |
|---|---|---|---|
Confluence 386600308 |
Only the most common requests, but with pointed field-by-field instructions | Wins on HOW to fill in a form | Confluence MCP tools (getConfluencePage) |
JSM portal 3168 |
Every request type, 6 groups, each form's own help text | Wins on WHICH forms exist | Browser required — authenticated SPA, curl will not work. Use Claude in Chrome |
JSM portal 3185 |
All feature requests / enhancement ideas | Sole authority for feature requests | Browser |
Intervals and per-source notes live in scripts/sources.json;
python3 scripts/check_freshness.py reports what is overdue.
Procedure:
- Fetch Confluence
386600308and diff it against the curated sections. - Browse JSM
3168: for each group (3328,3333,3329,3332,3330,3331) scrape the group page forcreate/<id>links, then open each form and read its top-of-form help text. Extract withdocument.body.innerText, slicing after the portal boilerplate —mainreturns the sidebar, not the form. Avoid returning rawhrefstrings; the safety filter blocks them. Usenew URL(a.href).pathname. - Check JSM
3185for changes to the feature-request route. - Reconcile. Record deprecated and superseded forms rather than deleting them — users still find them, and the redirect target is the useful part. Note dead links too.
- Where the sources conflict on which form exists, trust the portal; on how to fill it in, trust Confluence. Flag Confluence errors for the page owner instead of silently propagating them.
python3 scripts/check_freshness.py --mark <id>for each source, and commit.- Upload to every collection in
upload_targets(see below), then verify retrieval.
Every Knowledge-<Domain>/ folder gets both an _START_HERE.md routing guide and a
FAQ-<Domain>.md, from the first commit — even before any content exists. Copy
templates/_START_HERE.md and templates/FAQ-Domain.md and fill in the {{PLACEHOLDERS}}.
The FAQ exists from day one on purpose: without it, the first unsourced answer gets wedged into a derived file and the next reconciliation silently deletes it.
A scaffolded corpus has no agent and no Foundry collection. Until it graduates, its
domain's questions are still answered from wherever that content lives today (usually
Knowledge-BP-General/). The graduation sequence is in each scaffold's Becoming a real
corpus section — the order matters, because moving content out of a deployed collection
before the new one exists deletes it from a live agent.
Currently scaffolded, awaiting an agent: Knowledge-StatusPageAndSLA/ — Status Pages and
SLAs share one corpus and will share one agent. As of 2026-08-23 the upstream Blueprint
section is seven stub pages, so there is nothing to distil.
Knowledge-AlignedReleases/ graduated on 2026-08-23 — it has an agent and a collection.
Readiness is checked, not guessed. python3 scripts/check_freshness.py scans registered
upstream sources and reports ⏳ not ready or ✅ READY against thresholds in
scripts/sources.json (no stub markers left, and enough substantive lines). Only build the
corpus and agent when it says READY. A source path that doesn't exist on your machine reports
"not available here" rather than failing.
Knowledge-Shared/ deliberately breaks the one-folder-per-agent rule: its files go to
all writable collections, so any agent can answer directly instead of handing off. In a
direct (non-team) conversation there is nobody to hand off to, and the failure mode is an
invented ticket URL.
scripts/sources.json → upload_targets is the authoritative list. Today:
Knowledge-Shared/Conf-OneTylerTickets.md → OT-OpsCenter, OT-BPD, OT-SAC,
OT-AlignedReleases, TCP-KB-Identity — all five.
A change to a shared file that is not uploaded to every target leaves the copies drifting. Check all of them with the drift script before assuming you are done.
Full conventions are in README.md. The essentials:
-
.mdonly; filename is<Source>-<Topic>.md(Conf-,Docusaurus-,Training-,GitHub-,Misc-). -
FAQ-<Domain>.mdis the exception: one per agent corpus, authored here with no upstream source. It is the home of record for answers that exist nowhere else — verbal SME guidance, observed behaviour, ideal responses an upstream owner has not yet made. Put such content there, not in aDocusaurus-orConf-file, because those are re-derived from their sources and the next reconciliation would silently delete it. Every entry carriesSource/Added/Confidence/Promote when. Never add an unconfirmed claim: the agent will state it as fact. -
Optimize for retrieval, not source fidelity: clean markdown, a decision/lookup table near the top, self-contained sections (RAG chunks independently of headings), repeated explicit "Use when:" / "Prerequisites:" / "Fields:" patterns, and URLs preserved verbatim.
-
Add a "Notes for the chatbot" section for routing nuances and traps.
-
A behavioural rule must be CO-LOCATED with the data it governs, not only stated once. Retrievers chunk independently of headings, so a rule in a "Notes for the chatbot" section at the end of a long file is in a different chunk from the entry it is meant to modify — and the agent answering a specific question retrieves the entry, not the note. State the rule beside the data as well, and say in-file that the repetition is deliberate so nobody tidies it away.
Measured 2026-08-27: the rule "give the Confluence ticket page, not the raw JSM form URL" was added to
Conf-OneTylerTickets.md's notes at line 463 of a 777-line file. Retrieval probes confirmed the rule was live in all five collections — and the agent still handed out only the bare form URL, because the chunk it retrieved was the ticket entry at line 124. Content being retrievable is not the same as content being applied.The same logic applies to contradictions: if an entry elsewhere shows the thing the rule forbids, the agent will follow the concrete example over the general instruction. Fix the example, not just the rule.
-
Update
_START_HERE.mdin that folder whenever you add, rename, or remove a file. A stale start page actively misleads the agent. UpdateREADME.mdtoo if team-level routing changes. -
Never put a credential in a knowledge file. If the source has one, replace it with a pointer to the authoritative internal page (see
Knowledge-OpsCenter/Conf-GatewayOperationalTesting.mdfor the pattern).
| Symptom | Cause |
|---|---|
| 403 with a valid key | Missing User-Agent header, or the key belongs to another tenant |
| 401 | Key rotated or revoked — ask for a fresh one |
| HTML instead of JSON | Redirected to login; auth failed |
| 429 | Rate limited. General ~60 req/min; chat/stream ~20 req/min per user. Respect Retry-After |
| Exactly 200 results | Result cap hit — narrow the date range |
GET /api/agents/{id} → 404 |
Wrong route for config; use GET /api/configurable-agents/{id}. The /api/agents prefix only serves streaming/generation |
| Agent config shows fewer KB files than the collection | The config's file list is a stale cache. The collection endpoint is authoritative |
File ingested but agent says it can't find content |
Probe retrieval. Re-sync won't fix an empty index — delete and re-upload |
Files still ingesting after a few minutes |
Normal up to ~6 min; retrieval often live already. Assert on retrieved content. Past ~30 min, investigate |
POST /sync → "Content-Type must be application/json" |
Add -H "Content-Type: application/json"; not a transient error, retrying won't help |
Uploaded file shows application/octet-stream |
Missing ;type=text/markdown on the -F argument |
POST /sync → 500 |
Transient Bedrock job conflict; retry every ~45s |
| zsh poll loop breaks | Don't name a shell variable status — it's a read-only builtin |
| Prompt text comes back altered after a config PUT | Foundry HTML-escapes > and strips <tag>-shaped text. Never put < or > in a prompt; diff live-vs-mirror on content, since the length can be unchanged |
When you change the corpus, update in the same PR:
- The folder's
_START_HERE.md(file catalog + routing table). README.md, if team-level routing or the corpus list changed.- This file, if an ID, endpoint, or procedure changed.
- The re-upload list in your response.