Data flow
[Claude Code] ──hooks──▶ [~/.codika-os/events.db (SQLite)] │ │ codika-os sync ▼ ┌──────────────────────┐ │ Neon Postgres (app) │ └──────────┬───────────┘ │ /api/tracking/* ▼ [Dashboard UI]The five hooks (capture)
Section titled “The five hooks (capture)”Hook scripts live in codika-os-cli/src/cli/commands/hooks/. The codika-os install command writes five entries into ~/.claude/settings.json, each invoking codika-os hooks <name> with the Claude Code payload piped to stdin.
| Hook | When it fires | What it records |
|---|---|---|
SessionStart | New Claude Code session begins | session id, project path, model, started_at |
UserPromptSubmit | User sends a prompt | prompt length, timestamp, session id |
Stop | Agent finishes a turn | stopped_at |
SessionEnd | Session closes | ended_at |
PostToolUse | Any tool call completes | tool name, duration (agent-streaming seconds), session id |
Hooks must complete in under 50 ms and never make network calls. They open the SQLite file in WAL mode, append one row, close. Sync is a completely separate process.
Local store
Section titled “Local store”~/.codika-os/events.db is a SQLite database, WAL-mode, single-writer in practice. The hooks append; the aggregator reads.
~/.codika-os/config.json (mode 0600) holds your profiles and active CLI key. ~/.codika-os/last-sync is a timestamp file for the --if-stale guard on codika-os sync. The session_sync_state table inside ~/.codika-os/events.db holds per-session fingerprints used by incremental sync (below).
Sync (aggregation + upload)
Section titled “Sync (aggregation + upload)”codika-os sync runs:
- Fingerprint pre-pass. For every session present locally, the aggregator reads its current
user_promptcount (cheap SQLCOUNT) andstat()s its JSONL transcript (size + mtime). It compares the triple against the row insession_sync_state. Matches are clean; everything else is dirty. Clean sessions whose conversation root has any dirty sibling are upgraded to dirty so multi-session rollups don’t drift. codika-os-cli/src/core/aggregator.tswalks the dirty set only, joining hook events from local SQLite with the matching JSONL transcript per session.core/classifier.tspicks a project per session using longest-prefix path matching againstproject_path_prefixes. The classifier also records each matched prefix’ssubcategoryso the session can carryattribution_subcategory(for clients and other multi-scope projects). When no prefix matches, the session is uploaded withproject_id = NULLand left for the on-demand agent-inline classifier (see Classification).core/human-time.tscomputeshuman_secondsusing the weighted capped-gap convention.agent_secondsis summed fromPostToolUsedurations.core/jsonl-parser.tscomputescost_usd_centsfrom per-message token counts ×MODEL_RATES_PER_MTOK.core/uploader.tsPOSTs the rollup (only for dirty sessions) to/api/synconcodika-os-app, withAuthorization: Bearer <cos_…>from the active profile.- On 2xx upload, the aggregator’s fingerprint map is written back to
session_sync_state. A failed upload leaves the state untouched so the next run retries the same set.
Sync runs opportunistically when a new Claude Code session starts: the SessionStart hook checks ~/.codika-os/last-sync and, if it’s older than 20 hours, fires codika-os sync --if-stale --json as a detached background subprocess. A lockfile (~/.codika-os/sync.lock, atomic mkdir) ensures only one sync runs at a time. No system cron is involved — this works on any OS Claude Code supports. You can also force a sync any time with /codika-os:sync-codika-os or codika-os sync.
codika-os sync --full clears session_sync_state and re-processes every session. Use it after upgrading the aggregator (better classifier, improved human-time math) or to recover from state corruption.
Ingestion
Section titled “Ingestion”The endpoint is codika-os-app/src/routes/api/sync/+server.ts. It:
- Verifies the
cos_…bearer key againstcli_api_keys(hashed at rest), viasrc/lib/server/sync-auth.ts. - Opens a single transaction on a pooled Neon connection (
createPooledDb(mode)). All writes commit atomically. - Bulk-upserts
conversations,sessions(multi-rowINSERT ... ON CONFLICT DO UPDATE, chunks of 500) and bulk-insertssession_events,session_tool_calls(multi-rowINSERT ... ON CONFLICT DO NOTHING, deduped by thesession_events_dedupe_idxandsession_tool_calls_dedupe_idxunique indexes added in migration0007). - Appends one
project_attribution_attemptsrow per session withmethod = 'path'(audit trail). - Returns
{ success: true, data: { mode, duration_ms, conversations_upserted, sessions_upserted, events_inserted, tool_calls_inserted, attempts_inserted } }. Async_ingestJSON log line is emitted with the same fields — that’s the perf-regression tripwire.
Payload chunking. The CLI splits large syncs (more than 250 sessions) into multiple requests, grouped by conversation_root_id so each request is self-contained. Every chunk is idempotent on the server: re-sending a chunk produces zero duplicate rows.
Sync targets. codika-os sync writes to PROD by default — PROD is the source of truth. Pass --include-dev to additionally mirror to DEV (best-effort: DEV failures warn but don’t abort the command). --mode dev is single-target dev for local testing.
Three sibling endpoints support agent-inline classification of the residual:
GET /api/projects— returns the full project catalog (one entry perprojectsrow, with joinedpath_prefixesandcategory/subcategory/prompt_only). Used bycodika-os syncto load the classifier index.GET /api/tracking/unattributed?month=YYYY-MM— returns sessions withproject_id IS NULLfor the bearer’s worker, plus the active project catalog grouped by category.POST /api/sync/classifications— accepts agent-produced verdicts (projectSlug+ optionalsubcategory, orsuggestedSlug+suggestedCategory+ optionalsuggestedSubcategory) and applies them withmethod = 'agent-inline'.
The dashboard never writes to tracking tables. All inserts go through these endpoints.
Read path
Section titled “Read path”Pages under /tracking use SvelteKit server-loads that call aggregators in codika-os-app/src/lib/server/:
| Page | Aggregator |
|---|---|
/tracking (overview) | tracking-overview.ts |
/tracking/projects/[slug] | project-detail.ts |
/tracking/sessions/[id] | session-detail.ts |
/timesheet | session-projections.ts |
GET /api/projects (catalog for CLI sync) | routes/api/projects/+server.ts |
GET /api/tracking/unattributed (agent-inline classifier input) | routes/api/tracking/unattributed/+server.ts |
POST /api/sync/classifications (agent-inline classifier output) | routes/api/sync/classifications/+server.ts |
The aggregators are the source of truth for what each page sees. If a number on a page looks wrong, the aggregator is where you look — not the schema, not the CLI.
Where to change behavior
Section titled “Where to change behavior”| If you need to change… | Look here |
|---|---|
| What gets captured by hooks | codika-os-cli/src/cli/commands/hooks/*.ts |
| Project classification rules | codika-os-cli/src/core/classifier.ts |
| Project taxonomy (categories, subcategories, discovery rules) | codika-os-app/seeds/projects.yaml + scripts/syncProjects.ts |
| Human-time math | codika-os-cli/src/core/human-time.ts |
| Token cost math + model rates | codika-os-cli/src/core/jsonl-parser.ts |
| Sync payload shape | codika-os-cli/src/core/uploader.ts + src/types/api.ts |
| Incremental skip / fingerprint | codika-os-cli/src/core/aggregator.ts, core/local-db.ts (session_sync_state) |
/api/sync ingestion | codika-os-app/src/routes/api/sync/+server.ts |
| Schema | codika-os-app/src/lib/db/schema.ts |
| Migrations | codika-os-app/drizzle/*.sql |
A change in any of these usually requires an update to the matching file in another repo (e.g. schema change → CLI uploader update → docs update). The four repos are versioned independently but documented as one system.
Two auth systems
Section titled “Two auth systems”- Dashboard auth — Better Auth, the
/loginpage oncodika-os-app. Cookies-and-sessions. Used by humans to view the dashboard. - CLI auth — OTP-minted
cos_…keys at/api/auth/cli/*. Hashed incli_api_keys. Used by the CLI to talk to/api/sync.
They’re separate. Logging in to one doesn’t log you in to the other. dashboard_access is read from prod regardless of the app_mode cookie; tracking data queries follow the cookie (prod default).