What you'll build: a shared code knowledge base for a whole engineering org. A GitHub webhook streams every push and PR — across all repos and branches — into one DataK3 bucket, where the code embeds for intent search, symbol metadata lands in SQL, and design decisions (ADRs/PRs) sit next to the code they explain. Every developer's agent queries the same, always-current index over the DODIL MCP.
Why a shared index?
A real org's code sprawls across dozens of repos and hundreds of active branches. The tools each have a gap:
git grepand GitHub search are lexical — they match tokens, not intent ("where do we debounce reconnects?").- IDE / Copilot indexes are per-developer and per-branch — everyone re-indexes the same code, and it's stale the moment a teammate pushes.
- The "why" — the ADR, the PR discussion — lives detached from the code it governs.
For teams juggling many repositories, cross-repo and cross-branch intelligence is the deciding factor. One shared, intent-searchable, always-current index — synced from GitHub automatically and queried by every agent — is the payoff. This is that, in one bucket.
What you'll learn:
- Sync GitHub → DataK3 continuously with a webhook + an Ignite handler authed by a service account.
- Keep a branch-aware symbol table so search scopes to one branch or spans them all.
- Answer "where do we do X, on any branch, and why" across objects + Vector + SQL, CLI or agent.
NOTE
Connect the DODIL MCP once — see the two-minute setup. Each step below shows the CLI and an Ask your agent tab.
Problem
A developer — or the coding agent working on their behalf — burns real time answering "where do we do
X, on which branch, and why?" git grep is lexical and single-branch, IDE indexes are per-developer
and stale the moment a teammate pushes, and the ADR that explains the decision lives in a wiki detached
from the code. So the same questions get re-asked, the same code gets re-indexed, and the reasoning is
lost. This tutorial gives that developer's agent one shared, always-current index — code embedded for
intent search, symbols in SQL scoped by branch, and the "why" (ADRs + PR descriptions) sitting next to
the code — so any agent on the team answers "where + why, on any branch" in one query instead of a
manual hunt across repos and tools.
Prerequisites
- A DODIL organization, and the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent. - A GitHub org/repo where you can add a webhook (admin on the repo).
Step 1 — Create the shared bucket + recipe collections
One bucket for the whole org. Two recipes: code-rag for source (AST-aware chunking) and
document-rag for ADRs and PR descriptions. Each recipe stands up a collection — a table with a
jina-embeddings-v4 vector column — plus the ingest rule that auto-embeds anything landing under its
folder. There's no separate vector store to provision.
Create a DataK3 bucket called devbrain, then install code-rag with prefix dev scoped to repos/ and document-rag with prefix notes scoped to notes/.
data_bucket_create→data_recipe_installCreated devbrain. Installed code-rag (collection dev_code, AST-aware, jina-embeddings-v4) scoped to repos/, and document-rag (collection notes_docs) scoped to notes/. Anything synced under repos/ embeds into dev_code; ADRs under notes/ embed into notes_docs.
export BUCKET=devbrain
dodil data bucket create "$BUCKET" --description "Org code knowledge base: all repos + branches"
dodil data recipe install code-rag --bucket "$BUCKET" --name-prefix dev --folder repos/
dodil data recipe install document-rag --bucket "$BUCKET" --name-prefix notes --folder notes/IMPORTANT
One collection for all branches — not one per branch. The code-rag rule is scoped to the
repos/ folder, so every object — regardless of branch — embeds into the single dev_code
collection. You scope by branch with the symbols table's branch column (Step 3), not by
creating a collection per branch. A collection-per-branch would mean hundreds of short-lived
collections to spin up and tear down, re-embedding files that are identical across branches, and
fanning every cross-branch query across all of them. One collection + branch as metadata lets you
search a single branch or span them all. And because objects are keyed by commit (Step 4), a
file that's identical across branches is a single object — embedded once, not once per branch.
(Reserve a separate collection for a real isolation boundary — say, per repo when
languages/embedding models differ — never per branch.)
Step 2 — A service account for the sync (CI identity)
The sync runs in CI and in a webhook handler — no human at a browser — so it authenticates with a
service account, not your login. Create one, grant it the least-privilege DataK3 write role, and
store the ID + secret where the handler can read them as DODIL_SERVICE_ACCOUNT_ID /
DODIL_SERVICE_ACCOUNT_SECRET.
Create a service account called ci-code-sync and grant it k3.editor so my GitHub sync can write to devbrain non-interactively.
auth_service-account_create→auth_service-account_grant-roleCreated service account ci-code-sync (secret shown once — store it now). Granted k3-authorization-service/k3.editor — enough to write objects and tables, nothing more. The webhook handler and CI can now auth headlessly via the DODIL_SERVICE_ACCOUNT_ID/_SECRET env vars.
# secret is shown ONCE — capture it now
dodil auth service-account create ci-code-sync -o json
# grant DataK3 write (least privilege; use the UUID from the create output / `service-account list`)
dodil auth service-account grant-role $SA_UUID k3-authorization-service k3.editor
# store both where the handler runs (Ignite secrets, and/or GitHub Action secrets):
# DODIL_SERVICE_ACCOUNT_ID=$SA_UUID DODIL_SERVICE_ACCOUNT_SECRET=$SA_SECRETTIP
Scope it down and rotate it. Grant only what the sync needs (k3.editor, not k3.admin), and
dodil auth service-account rotate on a schedule — the secret only ever lived in your CI secret store.
Step 3 — A branch-aware symbol table
Create the table before the handler that fills it. One row per (branch, path), merge-keyed on a
synthetic entry_id (repo@branch:path) so the same file on different branches stays distinct, while
each row's s3_key points at the commit-addressed blob shared across branches. Because the table is
merge-keyed, the sync's writes are idempotent upserts — re-runs and shard retries are safe. The branch
column is what lets a search scope to one branch or span them all.
Create a symbols table in devbrain keyed on entry_id, with repo, branch, path, symbol, kind, lang, commit, and s3_key.
data_table_createCreated the symbols table with PRIMARY KEY entry_id (repo@branch:path) and a branch column (column_count 9). The sync handler (next step) upserts one row per (branch, path) — so a file on three branches is three rows pointing at the same commit-addressed blob.
dodil data table create symbols --bucket "$BUCKET" \
--columns-json '[
{"name":"entry_id","type":"string","nullable":false},
{"name":"repo","type":"string","nullable":false},
{"name":"branch","type":"string","nullable":false},
{"name":"path","type":"string","nullable":false},
{"name":"symbol","type":"string","nullable":true},
{"name":"kind","type":"string","nullable":true},
{"name":"lang","type":"string","nullable":true},
{"name":"commit","type":"string","nullable":true},
{"name":"s3_key","type":"string","nullable":false}
]' \
--merge-key entry_idStep 4 — Sync GitHub → DataK3 (the centerpiece)
Deploy an Ignite function as the webhook target. It handles three GitHub events: a push
(route changed source to repos/ and ADRs to notes/, and upsert symbol rows), a branch
created (seed the new branch from its base), and a pull request (capture the description to
notes/ — the why). Source objects are keyed by commit, not branch, so a file identical across
branches is stored and embedded once.
# sync.py (essence) — one Ignite function, three GitHub event paths.
# Headless auth via DODIL_SERVICE_ACCOUNT_ID / DODIL_SERVICE_ACCOUNT_SECRET.
def handle(event):
repo = event["repository"]
# PULL REQUEST — capture the "why": write the PR description under notes/ (embeds into notes_docs).
if event["kind"] == "pull_request":
data_put(BUCKET, f"notes/{repo}/pr-{event['number']}.md", event["body"])
return
# NEW BRANCH — a create event changes no files; seed the branch by copying the base
# branch's rows (same commit-pinned objects). No re-upload, no re-embed.
if event["kind"] == "branch_created":
for r in sql(f"SELECT path, commit, s3_key FROM symbols "
f"WHERE repo='{repo}' AND branch='{event['base_branch']}'"):
upsert_symbol(repo, event["branch"], r.path, r.commit, r.s3_key)
return
# PUSH — route source to repos/ (embeds into dev_code) and ADRs to notes/ (into notes_docs).
branch, commit = event["branch"], event["after"]
for path in event["changed_files"]:
if is_design_doc(path): # e.g. docs/adr/*.md, **/adr-*.md
data_put(BUCKET, f"notes/{repo}/{path}", github_raw(repo, commit, path))
continue # the "why" — embedded, not a symbol
key = f"repos/{repo}/{commit}/{path}" # commit-addressed source -> dev_code
if not data_exists(BUCKET, key):
data_put(BUCKET, key, github_raw(repo, commit, path)) # code-rag embeds it once
upsert_symbol(repo, branch, path, commit, key) # one row per (branch, path)
for path in event["deleted_files"]:
delete_symbol(repo, branch, path)So the handler populates all three pillars: the symbols table (Step 3), the dev_code
embeddings (source under repos/), and the notes_docs embeddings (ADRs + PR descriptions under
notes/). On a push it upserts the changed files; on a new branch it copies the base branch's rows,
so the branch is searchable the instant it exists — no re-upload, no re-embed. (Cut a branch from
somewhere other than the default? The seed uses your base, and the first real push corrects whatever
diverged — shared files still dedup by commit.)
Deploy my sync function to Ignite as a public webhook target and give me its URL so I can register it as a GitHub push/PR webhook.
ignite_app_deployDeployed sync (deployment_state deployed, public_invoke true) — URL https://sync-ihdiash-8080.ignite.dodil.cloud. Add it as a GitHub webhook (events: push, create, pull_request). Pushes route source to repos/ (→ dev_code) and ADRs to notes/ (→ notes_docs) and upsert symbol rows; PRs write their description to notes/; a new branch seeds instantly from its base.
# deploy the handler as a public endpoint; its URL is the webhook target
dodil ignite app deploy sync --code ./sync --runtime python --tier small \
--allow-unauthenticated --port 8080 --health-path /healthz
# GitHub → repo/org Settings → Webhooks → add the function URL, content-type
# application/json, events: push + create (branches) + pull_request.Step 5 — Search "where + why", across every branch
Now any developer's agent can ask the org-wide question. SQL narrows to the right symbols;
Vector finds the code by intent in dev_code and the why in notes_docs; and the object
store quotes the exact commit-pinned file and ADR. data search embeds the query server-side and runs
hybrid retrieval over a recipe collection — scope it to one collection with --collection, drop weak
hits with --min-score.
In devbrain, find the code that tunnels NATS traffic and reconnects (by intent, across all active branches), explain why the ping interval is 15s, and link the exact file on feature/tunnel-hardening and the ADR.
data_sql→data_search→data_object_urlCode (dev_code, by intent): copy_bidirectional in tunnel.rs — on main, feature/tunnel-hardening, and release/1.8 (3 branches, commit 3f9a1c). Why (notes_docs): ADR-0007 — the 15s ping is half the broker's 30s idle timeout, so a reconnect fires before the client is dropped. Links (valid 1h): …/dodil-ignite/3f9a1c/…/tunnel.rs?X-K3-Token=… · …/notes/adr-0007…?X-K3-Token=…
# (a) SQL: narrow to the right symbols across ALL active branches (drop the branch filter to span them)
dodil data sql -b "$BUCKET" \
"SELECT repo, branch, path, symbol, s3_key FROM symbols
WHERE lang='rust' AND (path LIKE '%nats%' OR symbol LIKE '%reconnect%')
ORDER BY branch"
# (b) Vector: find the CODE by intent (the code-rag collection over repos/)
dodil data search "bidirectional copy loop that tunnels NATS traffic and reconnects on drop" \
--bucket "$BUCKET" --collection dev_code --top-k 8 --min-score 0.25
# (c) Vector: find the WHY by intent (the ADRs + PR descriptions under notes/)
dodil data search "why is the NATS client ping interval 15 seconds; reconnect backoff" \
--bucket "$BUCKET" --collection notes_docs --top-k 8 --min-score 0.25
# (d) Object store: pre-signed link to the exact commit-pinned file + the ADR
dodil data object url repos/dodil-ignite/3f9a1c/crates/ignite-cluster/src/tunnel.rs \
--bucket "$BUCKET" --expires 3600Step 6 — Share work in progress: push a draft branch
No need to sync a laptop's uncommitted files. To share pre-review work, a developer pushes a draft branch — the webhook syncs it like any other, and a teammate's agent can search it seconds later. Freshly upserted symbol rows are visible to reads immediately (read-your-writes).
On devbrain, what's the new retry logic Sara is working on — search her draft branch spike/retry-backoff.
data_sql→data_search→data_object_urlOn spike/retry-backoff: with_backoff in retry.rs (added 2h ago, commit a1b2c3) — exponential backoff + jitter capped at 30s, honoring Retry-After. Not yet on main. Link to the draft file (valid 1h): …/dodil-ignite/a1b2c3/…/retry.rs?X-K3-Token=…
# Sara, sharing WIP without a PR:
git push origin spike/retry-backoff # webhook syncs it into devbrain
# a teammate's agent narrows to that branch:
dodil data sql -b "$BUCKET" \
"SELECT path, symbol, s3_key FROM symbols
WHERE branch='spike/retry-backoff' AND (symbol LIKE '%backoff%' OR path LIKE '%retry%')"DataK3 vs. bolting it together
| Concern | Typical stack | DataK3 |
|---|---|---|
| Code blobs + semantic index + symbol DB | Object store + vector DB + Postgres + an indexer service | One bucket; a recipe embeds files into a collection, a table holds symbols |
| Keep it fresh across branches | Kafka → stream processor → re-embed → upsert, self-run | GitHub webhook → Ignite function → object → rule embeds incrementally |
| "Find by intent, on this branch, and why" | Vector hit joined to a symbol DB, ADRs in a wiki elsewhere | SQL narrows, Vector ranks, ADRs are objects next to the code |
How this differs from Sourcegraph / Copilot
Semantic code search isn't new — Sourcegraph, Copilot, and Claude Context all do a version. What's different here isn't the search, it's the substrate: the code, the embeddings, the symbol table, the "why" (ADRs), and the sync compute live in one bucket on one platform, one bill — and every piece is agent-native over the MCP, so your agents build on it instead of scraping a UI.
Troubleshooting
- Re-embed cost. Re-embedding on every push adds up — debounce noisy branches, and let the rule embed incrementally (only changed objects) rather than rebuilding.
- Branch cleanup. When a branch merges or is deleted, tombstone its keys (or expire by prefix) so search doesn't surface dead branches.
- Secrets hygiene. The service-account secret lives only in your CI/Ignite secret store; rotate it,
and never sync
.env/credentials — scope thecode-rag--folder/rule includes to source and docs. - Chunking. The
code-ragcollection uses the AST-awarecode_embedding_indextemplate (tree-sitter), so recall follows functions/symbols, not arbitrary line windows.
Test
Verify the shared index end-to-end: the bucket exists, both recipes installed their collections, the
branch-aware symbols table is present, the sync handler is live, and both SQL and Vector return the
code and the why. Reads are read-your-writes by default, so freshly synced rows and objects show up
immediately — no compaction step needed (data table compact only drains the write-log for scan perf,
never for correctness).
export BUCKET=devbrain
# 1. The shared bucket exists
dodil data bucket get "$BUCKET"
# expect: bucket "devbrain" with description "Org code knowledge base: all repos + branches"
# 2. Both recipes installed their collections
dodil data vector collection list --bucket "$BUCKET"
# expect: dev_code (code-rag, AST-aware, scoped to repos/) and notes_docs (document-rag, scoped to notes/)
# 3. The branch-aware symbols table exists, keyed on entry_id with a branch column
dodil data table describe symbols --bucket "$BUCKET"
# expect: columns entry_id, repo, branch, path, symbol, kind, lang, commit, s3_key; merge-key entry_id
# 4. The sync handler is deployed (webhook target)
dodil ignite app get sync
# expect: app "sync" deployed, public_invoke true, with a URL to register as the GitHub webhook
# 5. SQL narrows to symbols across all active branches
dodil data sql -b "$BUCKET" \
"SELECT repo, branch, path, symbol FROM symbols WHERE lang='rust' ORDER BY branch LIMIT 5"
# expect: one row per (branch, path) for the synced Rust files — the same file on N branches = N rows
# 6. Vector finds the CODE by intent, and the WHY next to it
dodil data search "bidirectional copy loop that tunnels NATS traffic and reconnects on drop" \
--bucket "$BUCKET" --collection dev_code --top-k 8 --min-score 0.25
# expect: a hit in dev_code (e.g. copy_bidirectional in tunnel.rs) with a commit-addressed s3_key
dodil data search "why is the NATS client ping interval 15 seconds" \
--bucket "$BUCKET" --collection notes_docs --top-k 8 --min-score 0.25
# expect: a hit in notes_docs (the ADR explaining the 15s ping vs. broker idle timeout)One-shot
Hand this to an agent with the DODIL MCP connected to reproduce the whole tutorial end-to-end:
Build a shared GitHub → DataK3 code knowledge base in DODIL. Steps:
1. Create a DataK3 bucket "devbrain" (data_bucket_create). Install two recipes into it
(data_recipe_install): code-rag with name-prefix "dev" scoped to folder repos/ (collection dev_code,
AST-aware), and document-rag with name-prefix "notes" scoped to folder notes/ (collection notes_docs).
Each recipe stands up a collection + ingest rule — no separate vector store.
2. Create a service account "ci-code-sync" (auth_service-account_create) and grant it the least-privilege
write role k3-authorization-service/k3.editor (auth_service-account_grant-role). Store its id/secret as
DODIL_SERVICE_ACCOUNT_ID / DODIL_SERVICE_ACCOUNT_SECRET for the handler — never inline the secret.
3. Create a "symbols" table in devbrain (data_table_create), merge-keyed on entry_id (repo@branch:path),
with columns entry_id, repo, branch, path, symbol, kind, lang, commit, s3_key.
4. Deploy an Ignite function "sync" (ignite_app_deploy, --allow-unauthenticated) that handles GitHub push,
create (branch), and pull_request events: route source to repos/<repo>/<commit>/<path>
(commit-addressed, embeds into dev_code) and ADRs/PR bodies to notes/ (embeds into notes_docs), and
upsert one symbols row per (branch, path). On a new branch, seed rows from the base branch. Register its
invoke URL as the GitHub webhook (events: push, create, pull_request).
5. Answer "where + why, on any branch": use data_sql to narrow symbols, data_search over dev_code for
code-by-intent and over notes_docs for the "why", and data_object_url for a pre-signed link to the
exact commit-pinned file and ADR.
Then run the ## Test checks to confirm the bucket, both collections, the symbols table, the sync app,
and both SQL + Vector results are all present.Ship it
Step 4 already deploys the sync engine as a public webhook target — that one command is the ship:
dodil ignite app deploy sync --code ./sync --runtime python --tier small \
--allow-unauthenticated --port 8080 --health-path /healthzFor the full production lifecycle behind it — DODIL git → CI → a scanned image in the registry → a public endpoint, with versioning and one-command rollback — see the flagship walkthrough: Ship a DODIL App.
Conclusion
Every repo and branch, streamed from GitHub into one bucket, intent-searchable with the why attached — a shared code knowledge base a whole team's agents query, always current. One bucket, two recipes, a webhook, and a service account.
Next steps:
- Leads Data Warehouse — the same enrich-then-classify backbone.
- Agent Cache — cache the coding agent's prompt→answer pairs.