···11+# Kaneo — Project Context for Oz
22+33+## What This Project Is
44+55+Kaneo is a self-hosted Kanban/project management app (pnpm monorepo). I've forked it from the original
66+author and am hosting my fork on **tangled.org** (a decentralized Git platform built on AT Protocol / atproto).
77+88+The app already has a **two-way GitHub Issues sync**: create a task in Kaneo → issue appears on GitHub,
99+comment on GitHub → syncs back to Kaneo. The existing GitHub integration lives in:
1010+- Backend plugin: `apps/api/src/plugins/github/`
1111+- Backend routes/controllers: `apps/api/src/github-integration/`
1212+- Frontend fetchers: `apps/web/src/fetchers/github-integration/`
1313+- Frontend hooks: `apps/web/src/hooks/mutations/github-integration/` and `hooks/queries/github-integration/`
1414+- Frontend component: `apps/web/src/components/project/github-integration-settings.tsx`
1515+1616+## My Goal
1717+1818+Build a **Tangled/AT Protocol two-way sync integration** for Kaneo — mirroring the GitHub integration
1919+but for tangled.org. If you create a Kaneo task, it creates a `sh.tangled.repo.issue` record on the
2020+user's PDS (and vice versa). Comments, state changes, labels all sync both ways.
2121+2222+My fork lives at tangled.org. This is a learning project — I am new to TypeScript in a complex
2323+monorepo but have experience in Python, Go, React (light), and SQL.
2424+2525+**Fork URLs:**
2626+- Tangled (primary): `https://tangled.org/danieldaum.net/kaneo`
2727+- GitHub fork (for upstream PRs): `https://github.com/daniel-daum/kaneo`
2828+- Upstream: `https://github.com/usekaneo/kaneo`
2929+3030+## Mentorship Mode — IMPORTANT
3131+3232+**Do not generate large blocks of code for me unless I explicitly ask.**
3333+3434+My primary goal is to *learn* while building this. The preferred interaction style is:
3535+- Explain *why* before *how*
3636+- Show me the relevant existing code pattern, then ask me to try writing it
3737+- Point me to the file/function to look at, let me read it first
3838+- Review and correct what I write rather than writing it for me
3939+- When I'm stuck, give targeted hints rather than full solutions
4040+- If I ask "how do I do X", explain the concept and show a small example — don't implement X in the codebase for me
4141+4242+That said: I want to move at a reasonable pace. If something is purely boilerplate/mechanical (e.g.
4343+adding an env var to a config file, or creating an empty directory structure), go ahead and do it.
4444+4545+## Tangled / AT Protocol Architecture
4646+4747+Full reference docs are in `~/starforge/documentation/tangled/`:
4848+- `001-architecture-and-api.md` — API surface, XRPC endpoints, lexicon catalog, webhooks
4949+- `002-atproto-vs-tangled-boundary.md` — exactly which operations go to atproto vs knot vs appview
5050+5151+### Key facts to keep in mind:
5252+5353+**How Tangled works (two tiers):**
5454+1. **User's PDS** (AT Protocol) — stores records like issues, PRs, labels. Accessed via standard
5555+ `com.atproto.repo.*` methods. This is where ~80% of the work happens.
5656+2. **Knot servers** — host actual git repos. Expose XRPC endpoints for branches, diffs, trees, etc.
5757+ Require a service auth token obtained from the user's PDS.
5858+3. **AppView (tangled.org)** — server-rendered HTML only. NOT an API. Only used for display URLs
5959+ and as the `aud` value when getting service auth tokens (`did:web:tangled.org`).
6060+6161+**Creating an issue on Tangled** = writing a `sh.tangled.repo.issue` record to the user's PDS via
6262+`com.atproto.repo.createRecord`. No Tangled HTTP API involved.
6363+6464+**Knot discovery** — to call knot XRPC, read the `sh.tangled.repo` record from the owner's PDS
6565+and extract the `knot` field. Never hardcode knot hostnames.
6666+6767+**Service auth flow for knot calls:**
6868+1. Auth to user's PDS with handle + app password
6969+2. `com.atproto.server.getServiceAuth` with `aud: did:web:tangled.org`
7070+3. Get a short-lived bearer token (60s expiry)
7171+4. Use it in `Authorization: Bearer {token}` for knot XRPC calls
7272+7373+**Receiving events from Tangled** → use webhooks (documented at docs.tangled.org/webhooks).
7474+Webhooks are configured per-repo in the repo settings UI. Signed payloads, delivery retries.
7575+7676+**Relevant atproto collections:**
7777+- `sh.tangled.repo` — repo metadata (name, knot, description)
7878+- `sh.tangled.repo.issue` — issues (fields: `repo` as at-uri, `title`, `body`, `createdAt`)
7979+- `sh.tangled.repo.issue.comment` — issue comments
8080+- `sh.tangled.repo.issue.state` — state change record
8181+- `sh.tangled.label.op` — label apply/remove
8282+- `sh.tangled.label.definition` — label definitions
8383+8484+**Best third-party reference:** `tangled.org/zzstoatzz/tangled-mcp` — a Python MCP server that
8585+does atproto issue CRUD and knot XRPC calls. Study this when figuring out auth and record writes.
8686+8787+## Tech Stack (Kaneo)
8888+8989+See `CLAUDE.md` for full details. Summary:
9090+- **Backend**: Hono (Node.js), PostgreSQL + Drizzle ORM, Better Auth, Valibot for validation
9191+- **Frontend**: React 19, TanStack Router + Query, Vite, Tailwind CSS v4, Zustand
9292+- **Monorepo**: pnpm + TurboRepo
9393+- **Package manager**: pnpm (not npm/yarn)
9494+- Dev ports: API on 1337, web on 5173
9595+9696+## Implementation Roadmap
9797+9898+The full step-by-step plan is in `~/starforge/documentation/tangled/`. High-level phases:
9999+100100+1. **Setup & credentials** — env vars for Tangled handle/app-password, AT Protocol client library
101101+2. **Outbound sync (Kaneo → Tangled)** — when a task is created/updated/commented in Kaneo, write
102102+ the corresponding atproto record to the user's PDS
103103+3. **Inbound sync (Tangled → Kaneo)** — configure a Tangled webhook, handle incoming payloads,
104104+ update Kaneo tasks accordingly
105105+4. **UI** — settings page for connecting a Tangled repo to a Kaneo project (mirrors GitHub settings)
106106+5. **State reconciliation** — handle edge cases, prevent sync loops, label mapping
107107+108108+## Development Setup — Active Plan (updated 2026-04-18)
109109+### Current state snapshot
110110+- `origin` = `git@knot.danieldaum.net:danieldaum.net/kaneo` (tangled). No `upstream` or `github` remote configured yet.
111111+- HEAD detached at `383e545`; `updates.md` has been merged into this file (see appendix).
112112+- Local `main` has three personal commits on top of an old `upstream/main` (~408 commits behind `usekaneo/kaneo`):
113113+ - `27766df ci: add ci mirror to github` — personal; must NOT leak into an upstream PR
114114+ - `6ae6bf5 feat: add atproto/api sdk` — upstream-worthy; belongs on the feature branch
115115+ - `383e545 feat: add stubbed tangled plugin type` — upstream-worthy; belongs on the feature branch
116116+- `.tangled/workflows/mirror-to-github.yaml` force-pushes `main` from tangled → github on every push.
117117+- Upstream branch-naming convention (from `CONTRIBUTING.md` and recent merge commits): `feat/<name>` or `fix/<name>`. Chosen feature branch: **`feat/tangled-integration`**.
118118+### Git model
119119+Three remotes on the local repo (in the orbstack VM at `~/starforge/kaneo`):
120120+- `origin` → tangled knot (`git@knot.danieldaum.net:danieldaum.net/kaneo`) — primary forge, source of truth
121121+- `upstream` → `https://github.com/usekaneo/kaneo.git` — pristine upstream; rebase target and PR target
122122+- `github` → `git@github.com:daniel-daum/kaneo.git` — fork used as PR source (fed by the spindle mirror)
123123+Two long-lived branches on my fork:
124124+- `main` = `upstream/main` + a thin layer of **personal-only** commits (mirror workflow, flake, AGENTS.md). This is the branch that mirrors to GitHub and is never the PR source.
125125+- `feat/tangled-integration` = branched off `upstream/main`; contains the atproto SDK + plugin stub + all future integration work. This becomes the upstream PR.
126126+Flow when contributing:
127127+1. Work on `feat/tangled-integration` locally.
128128+2. Push to `origin` (tangled) → spindle mirrors to `github` → open/update a PR on the github fork targeting `usekaneo/kaneo:main`.
129129+3. When upstream advances, `git fetch upstream && git rebase upstream/main` on the feature branch.
130130+4. Keep `main` in sync by resetting to `upstream/main` and re-applying the personal commits on top.
131131+### Personal-file policy
132132+These files live **only** on `main` (or a local branch derived from main), never on the feature branch:
133133+- `AGENTS.md`
134134+- `flake.nix`, `flake.lock`, `.envrc`
135135+- `.tangled/workflows/**`
136136+- Anything we later add under a `.local/` directory
137137+Guardrails:
138138+- Always `git switch main` (or `jj new main@origin`) before editing those files. Never edit them while HEAD is on `feat/tangled-integration`.
139139+- Before pushing a feature branch: `git diff --name-only upstream/main..HEAD` — none of the paths above should appear.
140140+- Optional: a pre-push hook that fails if those paths appear on any `feat/*` branch.
141141+### Chronological checklist — do ONE step at a time
142142+Stop after each step, verify, then re-read this file before starting the next. Commands are illustrative — pick `git` or `jj` per your preference; `.jj/` is colocated so both work.
143143+1. **Commit the consolidated `AGENTS.md` on `main`.**
144144+ - `jj edit main` (or `git switch main` for plain git).
145145+ - `jj describe -m "docs: consolidate integration context into AGENTS.md"` then `jj new` to open a fresh empty `@`.
146146+ - Verify `jj status` is clean; `jj log -r main..@` shows the new commit.
147147+2. **Add the `upstream` and `github` remotes, fetch them.**
148148+ - `git remote add upstream https://github.com/usekaneo/kaneo.git`
149149+ - `git remote add github git@github.com:daniel-daum/kaneo.git`
150150+ - `git fetch upstream && git fetch github`
151151+ - Confirm `git log --oneline upstream/main | head` shows ~408 new commits.
152152+3. **Safety net before rewriting history.**
153153+ - `git tag backup/pre-rewrite-main main`
154154+ - `git push origin refs/tags/backup/pre-rewrite-main`
155155+4. **Create `feat/tangled-integration` from `upstream/main` with only the upstream-worthy commits.**
156156+ - `git switch -c feat/tangled-integration upstream/main`
157157+ - `git cherry-pick 6ae6bf5 383e545` # atproto sdk + stubbed plugin
158158+ - Run `pnpm install` and `pnpm lint` to ensure it still builds against new upstream.
159159+ - `git push -u origin feat/tangled-integration`
160160+5. **Rewrite `main` = upstream/main + personal commits only.**
161161+ - `git switch main`
162162+ - `git reset --hard upstream/main`
163163+ - `git cherry-pick 27766df` # ci: add ci mirror to github (personal)
164164+ - Re-apply personal files if they are no longer present: `AGENTS.md` and (later) `flake.nix`.
165165+ - `git push origin main --force-with-lease`
166166+ - Spindle mirrors the new `main` to github; verify on the github fork.
167167+6. **Extend spindle workflow so feature branches also mirror to github.**
168168+ - Edit `.tangled/workflows/mirror-to-github.yaml` on `main`: expand `when.branch` to `["main", "feat/*", "fix/*"]` and push each ref generically (`git push github HEAD:refs/heads/$BRANCH --force`).
169169+ - Commit on `main`, push. Then push a no-op commit (or `--allow-empty`) to `feat/tangled-integration` and confirm it appears on the github fork.
170170+7. **Add the Nix dev flake (minimal profile).**
171171+ - On `main`: add `flake.nix` with inputs `nixpkgs`, output `devShells.default` containing: `nodejs_22`, `corepack_22` (for `pnpm@10.32.1` pinning), `git`, `jj`.
172172+ - Optional `.envrc` with `use flake` for direnv.
173173+ - Commit: `chore(nix): add dev flake`.
174174+ - Inside the VM: `nix develop` → `corepack enable` → `pnpm install` → `pnpm dev` must work.
175175+8. **Open a draft PR on the github fork.**
176176+ - From `daniel-daum:feat/tangled-integration` → `usekaneo:main`.
177177+ - Mark as draft; fill out `.github/PULL_REQUEST_TEMPLATE.md`.
178178+ - Keep as draft until the integration is end-to-end usable.
179179+9. **Resume integration work** from "Next Session — Start Here" below. All subsequent feature commits land on `feat/tangled-integration`, get mirrored to github, and accumulate on the draft PR.
180180+### Pitfalls & traps
181181+- **Leaking personal files into the PR.** The single biggest risk. Always diff against `upstream/main` before pushing `feat/*`.
182182+- **Force-pushing `main`.** The mirror workflow already uses `--force`, so this is acceptable. The rule is: never open a PR *from* `main` — always from `feat/*`.
183183+- **`pnpm-lock.yaml` churn.** Pulling 408 upstream commits will change the lockfile and may change Node engine constraints. Always re-run `pnpm install` after the rebase/reset.
184184+- **Drizzle migrations drift.** Upstream likely added migrations. Expect to rebuild the local Postgres or run pending migrations after the upstream pull.
185185+- **jj colocation quirks.** After the `reset --hard` and cherry-picks on `main`, run `jj git import` and verify `jj log` matches. Use `jj bookmark set main -r @` if bookmarks fall behind.
186186+- **SSH/PAT paths differ per remote.** `origin` (ssh → knot), `github` (ssh → github.com), `upstream` (https, read-only). Confirm `ssh -T git@github.com` succeeds inside the VM. The spindle's `GITHUB_PAT` secret must still have `repo` scope for the mirror to push.
187187+- **Spindle only knows `main` today.** If step 6 is skipped, feature branches will not appear on the github fork and no PR can be opened.
188188+- **Branch-naming drift.** Upstream uses `feat/…` / `fix/…`. Do not invent other prefixes — their maintainers may close PRs that do not match.
189189+- **@atproto/api session expiry.** Sessions expire; cache and `agent.resumeSession` rather than logging in per request (see appendix below).
190190+- **Sync loop prevention.** When a Tangled webhook arrives, don't re-fire outbound sync. Plan an idempotency key before writing the first outbound handler.
191191+- **App password scopes.** The Tangled app password must permit PDS writes for the target collections (`sh.tangled.repo.issue`, `.comment`, `.state`, `sh.tangled.label.op`). Verify against `tangled/001-architecture-and-api.md` before generating the token.
192192+- **Biome scope.** Biome only lints JS/TS, so `flake.nix`, YAML, and markdown are safe; still run `pnpm lint` after every significant change.
193193+194194+## Session Progress
195195+196196+### Session 1 (2026-03-20)
197197+**Completed:**
198198+- Installed `@atproto/api` as a dependency in `apps/api/package.json`
199199+- Created `apps/api/src/plugins/tangled/` directory
200200+- Wrote `config.ts` — Valibot schema (`tangledConfigSchema`), inferred `TangledConfig` type,
201201+ and `validateTangledConfig` async function
202202+- Wrote `index.ts` — `tangledPlugin: IntegrationPlugin` object with stubbed async handler
203203+ functions for all task events (`onTaskCreated`, `onTaskStatusChanged`, `onTaskTitleChanged`,
204204+ `onTaskDescriptionChanged`, `onTaskPriorityChanged`, `onTaskCommentCreated`)
205205+206206+**Config shape (may expand later):**
207207+- `repoAtUri: string` — the AT-URI of the Tangled repo to sync with
208208+ (e.g. `at://did:plc:abc123.../sh.tangled.repo/reponame`)
209209+210210+**Concepts covered this session:**
211211+- Valibot: runtime validation library, similar to Python's pydantic. Schema = source of truth;
212212+ `v.InferOutput` derives the TS type from it. Used for API input validation and plugin config validation.
213213+- `try/catch` with `v.parse()`: parse throws on failure, catch returns structured errors.
214214+- `IntegrationPlugin` interface in `plugins/types.ts` is the contract all plugins must satisfy.
215215+- Handler function naming: no collision risk since each plugin lives in its own module scope.
216216+- Prefer named function declarations over arrow functions — valid style, scales well when
217217+ handlers move to their own files under `events/`.
218218+219219+---
220220+221221+### Next Session — Start Here
222222+223223+**Step 1: Register the plugin**
224224+Find where `githubPlugin` is passed to the plugin registry and where `initializeGitHubPlugin()`
225225+is called in the main API app. Add `tangledPlugin` alongside it.
226226+Likely in `apps/api/src/index.ts` or a `plugins/index.ts` — grep for `githubPlugin` to find it.
227227+228228+**Step 2: Add env vars**
229229+Add `TANGLED_HANDLE` and `TANGLED_APP_PASSWORD` to:
230230+- The root `.env` file
231231+- Wherever env vars are declared/typed in the API (look for `env.ts` or `config.ts` near
232232+ `apps/api/src/` — grep for `GITHUB_` to find the pattern)
233233+234234+These are the credentials used to authenticate to the user's PDS via `@atproto/api`.
235235+236236+**Step 3: Write an atproto client helper**
237237+Once env vars are in place, create a small helper (e.g. `apps/api/src/plugins/tangled/client.ts`)
238238+that authenticates with `@atproto/api` using those env vars and exports a ready-to-use client.
239239+This will be used by all the event handlers.
240240+241241+---
242242+243243+## Working Notes
244244+245245+- The GitHub plugin uses an **event system** (`publishEvent`) to react to Kaneo-side changes.
246246+ The Tangled plugin will follow the same pattern.
247247+- Tangled webhooks will play the role that GitHub webhooks play in the existing integration.
248248+- There is no official AT Protocol TypeScript SDK maintained by Tangled; use the `@atproto/api`
249249+ package (Bluesky's SDK) — it works for any PDS including Tangled's.
250250+- Watch out for sync loops: when Kaneo handles a Tangled webhook, it must not re-fire outbound sync.
251251+252252+## Appendix — Full Integration Context (merged from updates.md on 2026-04-18)
253253+Sections above are the current canonical plan. This appendix preserves the deeper reference material originally drafted in `updates.md`.
254254+255255+# Kaneo × Tangled Integration — Context Document
256256+257257+> Generated 2026-04-16. Drop this file into any Claude/Warp session for full context.
258258+259259+---
260260+261261+## 1. What Is Kaneo
262262+263263+Self-hosted TypeScript kanban/project-management app. pnpm monorepo (Turbo). MIT license.
264264+265265+- **Backend**: Hono (Node.js), PostgreSQL + Drizzle ORM, Better Auth, Valibot validation
266266+- **Frontend**: React 19, TanStack Router + Query, Vite, Tailwind v4, Zustand
267267+- **Upstream GitHub**: https://github.com/usekaneo/kaneo
268268+- **Your Tangled fork**: https://tangled.org/danieldaum.net/kaneo
269269+- **Dev ports**: API `1337`, web `5173`
270270+- **Package manager**: pnpm (pinned 10.28.0), Node ≥ 18
271271+272272+Kaneo already has a fully working **two-way GitHub Issues sync** and a recently added **Gitea integration**. These are the direct models for the Tangled integration.
273273+274274+---
275275+276276+## 2. Existing Integration Architecture (your reference model)
277277+278278+### File layout — GitHub integration
279279+280280+```
281281+apps/api/src/plugins/github/ ← event handler plugin (outbound sync)
282282+apps/api/src/github-integration/ ← Hono routes + controllers (inbound webhook + CRUD)
283283+apps/web/src/fetchers/github-integration/
284284+apps/web/src/hooks/mutations/github-integration/
285285+apps/web/src/hooks/queries/github-integration/
286286+apps/web/src/components/project/github-integration-settings.tsx
287287+```
288288+289289+### Plugin system (`apps/api/src/plugins/types.ts`)
290290+291291+All integrations implement `IntegrationPlugin`:
292292+293293+```ts
294294+interface IntegrationPlugin {
295295+ type: string;
296296+ name: string;
297297+ validateConfig(
298298+ config: unknown,
299299+ ): Promise<{ valid: boolean; errors?: string[] }>;
300300+ onTaskCreated(event: TaskCreatedEvent, ctx: PluginContext): Promise<void>;
301301+ onTaskStatusChanged(
302302+ event: TaskStatusChangedEvent,
303303+ ctx: PluginContext,
304304+ ): Promise<void>;
305305+ onTaskTitleChanged(
306306+ event: TaskTitleChangedEvent,
307307+ ctx: PluginContext,
308308+ ): Promise<void>;
309309+ onTaskDescriptionChanged(
310310+ event: TaskDescriptionChangedEvent,
311311+ ctx: PluginContext,
312312+ ): Promise<void>;
313313+ onTaskPriorityChanged(
314314+ event: TaskPriorityChangedEvent,
315315+ ctx: PluginContext,
316316+ ): Promise<void>;
317317+ onTaskCommentCreated(
318318+ event: TaskCommentCreatedEvent,
319319+ ctx: PluginContext,
320320+ ): Promise<void>;
321321+}
322322+```
323323+324324+### Database tables (already exist, reuse for Tangled)
325325+326326+| Table | Purpose |
327327+| --------------- | ------------------------------------------------------------------------------------------ |
328328+| `integration` | Generic row per project, `type` field = `"github"` / `"gitea"` / `"tangled"` |
329329+| `external_link` | Maps `task_id → external_id (rkey)` + `integration_id`, `resource_type`, `url`, `metadata` |
330330+| `workflow_rule` | Optional: auto-move task column on external event |
331331+| `activity` | Task timeline; has `external_source`, `external_user_name`, `external_url` fields |
332332+333333+A dedicated `github_integration` table (project_id unique FK, repo owner/name, installation_id) exists as a model — you'll want an analogous `tangled_integration` table.
334334+335335+---
336336+337337+## 3. Tangled / AT Protocol Architecture
338338+339339+### Two-tier model
340340+341341+1. **User's PDS** (AT Protocol server) — stores all forge records (issues, PRs, labels). Accessed via standard `com.atproto.repo.*` XRPC methods. This is ~80% of the work for issue sync.
342342+2. **Knot server** — hosts actual git repos. Exposes XRPC endpoints for git operations. Requires a service auth token. **Not needed for issue sync.**
343343+3. **AppView (tangled.org)** — server-rendered HTML only. Not an API. Used only for display URLs.
344344+345345+### Key principle
346346+347347+**Creating/updating a Tangled issue = writing an atproto record to the user's PDS.**
348348+There is no Tangled REST API for this. No Tangled app registration needed.
349349+350350+### Auth flow (for PDS writes)
351351+352352+```
353353+1. AtpAgent.login({ identifier: TANGLED_HANDLE, password: TANGLED_APP_PASSWORD })
354354+2. All com.atproto.repo.* calls are now authenticated
355355+3. No OAuth, no app installation — just handle + app password
356356+```
357357+358358+Service auth tokens (`com.atproto.server.getServiceAuth`) are only needed for knot XRPC (git ops). Skip for issue sync.
359359+360360+### Relevant atproto collections (lexicons)
361361+362362+| Collection | Purpose | Key fields |
363363+| ------------------------------- | ------------------ | --------------------------------------------- |
364364+| `sh.tangled.repo` | Repo metadata | `name`, `knot`, `description` |
365365+| `sh.tangled.repo.issue` | Issue record | `repo` (at-uri), `title`, `body`, `createdAt` |
366366+| `sh.tangled.repo.issue.comment` | Comment on issue | `issue` (at-uri), `body`, `createdAt` |
367367+| `sh.tangled.repo.issue.state` | State change | `issue` (at-uri), `open` (bool) |
368368+| `sh.tangled.label.op` | Apply/remove label | `issue`, `label`, `op` |
369369+| `sh.tangled.label.definition` | Label definition | `name`, `color` |
370370+371371+### Creating an issue (pseudocode)
372372+373373+```ts
374374+await agent.api.com.atproto.repo.createRecord({
375375+ repo: agent.session.did, // your DID
376376+ collection: "sh.tangled.repo.issue",
377377+ record: {
378378+ $type: "sh.tangled.repo.issue",
379379+ repo: config.repoAtUri, // "at://did:plc:xxx/sh.tangled.repo/reponame"
380380+ title: task.title,
381381+ body: task.description ?? "",
382382+ createdAt: new Date().toISOString(),
383383+ },
384384+});
385385+// Response: { uri, cid } — store uri/rkey in external_link.external_id
386386+```
387387+388388+### Knot discovery (not needed for issues, FYI)
389389+390390+Read the `sh.tangled.repo` record from owner's PDS, extract `knot` field. Never hardcode knot hostnames.
391391+392392+### Receiving events (inbound sync)
393393+394394+Tangled sends **webhooks** — signed HTTP POST payloads to a URL you configure in repo settings UI.
395395+396396+- Docs: https://docs.tangled.org/webhooks
397397+- Signature: HMAC-SHA256, header `X-Tangled-Signature` (verify before processing)
398398+- Events include: `issue.created`, `issue.state`, `issue.comment.created`, etc.
399399+- Delivery retries on failure
400400+401401+### Best reference implementation
402402+403403+`tangled.org/zzstoatzz/tangled-mcp` — Python MCP server doing atproto issue CRUD + knot XRPC. Study for auth and record write patterns.
404404+405405+---
406406+407407+## 4. What You've Already Built (Session 1, 2026-03-20)
408408+409409+### Commits on your fork
410410+411411+- `6ae6bf57` — `feat: add atproto/api sdk` — `@atproto/api` added to `apps/api/package.json`
412412+- `383e5457` — `feat: add stubbed tangled plugin type` — scaffolded plugin, AGENTS.md added
413413+414414+### Files created
415415+416416+```
417417+apps/api/src/plugins/tangled/config.ts ← Valibot schema, TangledConfig type, validateTangledConfig()
418418+apps/api/src/plugins/tangled/index.ts ← tangledPlugin: IntegrationPlugin (all handlers stubbed)
419419+AGENTS.md ← agent context doc (Oz/mentorship mode)
420420+```
421421+422422+### Config shape (current)
423423+424424+```ts
425425+// config.ts
426426+export const tangledConfigSchema = v.object({
427427+ repoAtUri: v.string(), // e.g. "at://did:plc:abc123/sh.tangled.repo/reponame"
428428+});
429429+export type TangledConfig = v.InferOutput<typeof tangledConfigSchema>;
430430+```
431431+432432+### Plugin shape (current — all handlers are empty `{}`)
433433+434434+```ts
435435+export const tangledPlugin: IntegrationPlugin = {
436436+ type: "tangled",
437437+ name: "Tangled",
438438+ onTaskCreated: handleTaskCreated,
439439+ onTaskStatusChanged: handleTaskStatusChanged,
440440+ onTaskPriorityChanged: handleTaskPriorityChanged,
441441+ onTaskTitleChanged: handleTaskTitleChanged,
442442+ onTaskDescriptionChanged: handleTaskDescriptionChanged,
443443+ onTaskCommentCreated: handleTaskCommentCreated,
444444+ validateConfig: validateTangledConfig,
445445+};
446446+```
447447+448448+---
449449+450450+## 5. Remaining Work — Ordered Roadmap
451451+452452+### Phase 1 — Wire up the plugin (no auth yet)
453453+454454+- [ ] Find where `githubPlugin` is registered — grep `githubPlugin` in `apps/api/src/index.ts` or nearby `plugins/index.ts`
455455+- [ ] Register `tangledPlugin` alongside it in the same place
456456+- [ ] Add `TANGLED_HANDLE` and `TANGLED_APP_PASSWORD` to `.env.sample` and the API's env config (grep `GITHUB_APP_ID` to find the pattern file)
457457+458458+### Phase 2 — atproto client helper
459459+460460+- [ ] Create `apps/api/src/plugins/tangled/client.ts`
461461+- [ ] Use `AtpAgent` from `@atproto/api`, login with env vars, export the authenticated agent
462462+- [ ] Handle re-auth (sessions expire; catch auth errors and re-login)
463463+464464+### Phase 3 — Outbound sync (Kaneo → Tangled)
465465+466466+- [ ] `handleTaskCreated`: `createRecord` → `sh.tangled.repo.issue` → store returned `uri` rkey in `external_link`
467467+- [ ] `handleTaskStatusChanged`: `createRecord` → `sh.tangled.repo.issue.state` (open/closed)
468468+- [ ] `handleTaskCommentCreated`: `createRecord` → `sh.tangled.repo.issue.comment`
469469+- [ ] `handleTaskTitleChanged` / `handleTaskDescriptionChanged`: `updateRecord` on the issue record
470470+- [ ] Status mapping: `to-do`/`in-progress` → `open: true`; `done`/`archived` → `open: false`
471471+472472+### Phase 4 — Database schema
473473+474474+- [ ] Add `tangled_integration` table to `apps/api/src/database/schema.ts`:
475475+ - `id` (CUID2), `project_id` (unique FK → project), `repo_at_uri`, `is_active`, `created_at`, `updated_at`
476476+- [ ] Reuse existing `integration` table (type `"tangled"`) and `external_link` table as-is
477477+- [ ] Run: `pnpm --filter @kaneo/api db:generate`
478478+479479+### Phase 5 — Inbound sync (Tangled → Kaneo)
480480+481481+- [ ] Add Hono route: `POST /tangled-integration/webhook` in new `apps/api/src/tangled-integration/`
482482+- [ ] Verify `X-Tangled-Signature` HMAC before processing
483483+- [ ] Handle `issue.created` → create Kaneo task + `external_link`
484484+- [ ] Handle `issue.state` → update task status column
485485+- [ ] Handle `issue.comment.created` → add activity record with `external_source: "tangled"`
486486+- [ ] **Loop prevention**: check `external_source` flag so inbound webhook handler does not re-fire outbound plugin events
487487+488488+### Phase 6 — API routes (CRUD for integration config)
489489+490490+- [ ] Mirror `apps/api/src/github-integration/` controllers:
491491+ - `POST /tangled-integration` — save config (repo_at_uri), create `tangled_integration` row
492492+ - `GET /tangled-integration/:projectId` — fetch config
493493+ - `DELETE /tangled-integration/:projectId` — remove (cascade deletes external_links)
494494+ - `POST /tangled-integration/:projectId/import` — bulk import existing Tangled issues as tasks
495495+496496+### Phase 7 — Frontend
497497+498498+- [ ] `apps/web/src/fetchers/tangled-integration/` — fetch/mutate integration config
499499+- [ ] `apps/web/src/hooks/queries/tangled-integration/` + `hooks/mutations/tangled-integration/`
500500+- [ ] `apps/web/src/components/project/tangled-integration-settings.tsx` — mirrors GitHub settings component
501501+ - Input: paste the repo's AT-URI (simpler than GitHub — no OAuth app install)
502502+ - Toggle active/inactive
503503+ - Import issues button
504504+505505+### Phase 8 — Edge cases
506506+507507+- [ ] Idempotency: check `external_link` before creating duplicate records
508508+- [ ] Handle Tangled record deletions (webhook `issue.deleted` if it exists)
509509+- [ ] Label sync (optional, `sh.tangled.label.op` + `sh.tangled.label.definition`)
510510+511511+---
512512+513513+## 6. Git Workflow & Remote Setup
514514+515515+### Remote topology
516516+517517+```
518518+usekaneo/kaneo (upstream — source of truth)
519519+ ↓ manual pull via "upstream" remote
520520+local machine
521521+ ├── main → push → tangled:danieldaum.net/kaneo main
522522+ │ → spindle CI → github:yourusername/kaneo main
523523+ └── feat/tangled-integration
524524+ → push → tangled:danieldaum.net/kaneo feat/tangled-integration
525525+ → spindle CI → github:yourusername/kaneo feat/tangled-integration
526526+```
527527+528528+### One-time local remote setup
529529+530530+```bash
531531+# Rename existing origin if needed, then add all three
532532+git remote add upstream https://github.com/usekaneo/kaneo.git
533533+git remote add tangled git@knot.danieldaum.net:danieldaum.net/kaneo
534534+git remote add github https://github.com/yourusername/kaneo.git
535535+# verify
536536+git remote -v
537537+```
538538+539539+### Pulling upstream updates (run periodically)
540540+541541+```bash
542542+git fetch upstream
543543+git checkout main
544544+git rebase upstream/main # keeps history clean
545545+git push tangled main # spindle CI then mirrors to github main automatically
546546+```
547547+548548+### Daily dev workflow
549549+550550+```bash
551551+# Keep base current before starting work
552552+git fetch upstream && git rebase upstream/main
553553+554554+# Work on your feature branch
555555+git checkout feat/tangled-integration # create with -b if first time
556556+# ... make commits ...
557557+git push tangled feat/tangled-integration
558558+# spindle CI mirrors it to github feat/tangled-integration automatically
559559+560560+# When ready to PR upstream:
561561+# Open PR on GitHub: yourusername/kaneo feat/tangled-integration → usekaneo/kaneo main
562562+```
563563+564564+### Spindle CI workflow (current → updated)
565565+566566+**Current** `.tangled/workflows/mirror-to-github.yaml` only triggers on `main` and hardcodes the branch ref. This means feature branch commits are never mirrored.
567567+568568+**Updated workflow:**
569569+570570+```yaml
571571+# .tangled/workflows/mirror-to-github.yaml
572572+573573+when:
574574+ - event: ["push", "manual"]
575575+ branch: ["main", "feat/*"]
576576+577577+engine: "nixery"
578578+579579+clone:
580580+ depth: 0
581581+582582+dependencies:
583583+ nixpkgs:
584584+ - git
585585+586586+steps:
587587+ - name: "Mirror to GitHub"
588588+ command: |
589589+ git fetch --unshallow origin || true
590590+ git fetch origin refs/heads/${SPINDLE_BRANCH}:refs/heads/${SPINDLE_BRANCH}
591591+ git remote add github https://x-access-token:${GITHUB_PAT}@github.com/${GITHUB_USERNAME}/${GITHUB_REPO_NAME}.git
592592+ git push github refs/heads/${SPINDLE_BRANCH}:refs/heads/${SPINDLE_BRANCH} --force-with-lease
593593+ environment:
594594+ GITHUB_USERNAME: "YOUR-GITHUB-USERNAME"
595595+ GITHUB_REPO_NAME: "YOUR-GITHUB-REPO-NAME"
596596+ # GITHUB_PAT must be set as a spindle secret, not here
597597+```
598598+599599+**Changes from original:**
600600+601601+- `branch: ["main", "feat/*"]` — now triggers on feature branches too
602602+- `${SPINDLE_BRANCH}` — dynamic branch name instead of hardcoded `main`
603603+- `--force-with-lease` instead of `--force` — won't overwrite GitHub commits you haven't seen
604604+- `GITHUB_PAT` stays in secrets
605605+606606+⚠️ **Verify the branch variable name.** `SPINDLE_BRANCH` is the expected name based on spindle's architecture but confirm it by adding `echo "Branch: ${SPINDLE_BRANCH}"` as a first step on your next run. Check `tangled.org/tangled.org/core/tree/master/spindle` if unsure.
607607+608608+---
609609+610610+## 7. Contributing Back to Upstream Kaneo
611611+612612+### The standard OSS flow (what CONTRIBUTING.md says)
613613+614614+Kaneo uses the standard GitHub fork → feature branch → PR model. You do **not** need prior write access.
615615+616616+**Step-by-step:**
617617+618618+```bash
619619+# 1. Fork usekaneo/kaneo on GitHub (click Fork in the UI) → creates github.com/yourusername/kaneo
620620+# 2. Clone your GitHub fork locally
621621+git clone https://github.com/yourusername/kaneo.git
622622+cd kaneo
623623+624624+# 3. Add upstream as a remote so you can stay in sync
625625+git remote add upstream https://github.com/usekaneo/kaneo.git
626626+627627+# 4. Create a feature branch (their convention: feat/ prefix)
628628+git checkout -b feat/tangled-integration
629629+630630+# 5. Do your work, commit using conventional commits
631631+git commit -m "feat: add Tangled/AT Protocol two-way issue sync integration"
632632+633633+# 6. Keep your branch up to date with upstream main
634634+git fetch upstream
635635+git rebase upstream/main
636636+637637+# 7. Push to YOUR GitHub fork
638638+git push origin feat/tangled-integration
639639+640640+# 8. Open a PR on GitHub from your fork's branch → usekaneo/kaneo main
641641+```
642642+643643+### Your Tangled fork vs. your GitHub fork — two separate things
644644+645645+- `tangled.org/danieldaum.net/kaneo` — your experimental/learning fork, hosted on Tangled. This is where you develop.
646646+- `github.com/yourusername/kaneo` — the GitHub fork you create specifically to open a PR upstream.
647647+648648+You can keep both in sync by pushing to both remotes, or just cherry-pick your Tangled commits over to the GitHub fork when ready.
649649+650650+### Before opening the PR
651651+652652+- Run `pnpm lint` (Biome) — the pre-commit hook will block if this fails
653653+- Run `pnpm build` — full monorepo build check (also runs in pre-commit)
654654+- Discuss with the maintainer (Discord or a GitHub Discussion) before building the full feature — CONTRIBUTING.md says "Have an idea? Let's discuss it first" for new features
655655+- Open a GitHub Discussion or Issue titled something like "feat: Tangled/AT Protocol integration (mirrors Gitea integration)" to get a thumbs-up before submitting a large PR
656656+657657+### Permissions
658658+659659+You have no write access to `usekaneo/kaneo` — that's correct and expected. The fork + PR model means you never need it. The maintainer merges your PR.
660660+661661+### PR description checklist (what makes a clean PR)
662662+663663+- What: short summary of what the integration does
664664+- Why: link to Tangled, note it mirrors the existing Gitea integration pattern
665665+- How: list the files added/changed, note the new env vars required (`TANGLED_HANDLE`, `TANGLED_APP_PASSWORD`)
666666+- Testing: describe how a reviewer can test it locally (what to configure, what to observe)
667667+- i18n: add any new UI strings to `i18n/en-US.json` (their CONTRIBUTING.md requires this)
668668+669669+---
670670+671671+## 7. Key File Paths Quick Reference
672672+673673+```
674674+apps/api/src/plugins/tangled/config.ts ← YOUR FILE (exists)
675675+apps/api/src/plugins/tangled/index.ts ← YOUR FILE (exists, stubbed)
676676+apps/api/src/plugins/tangled/client.ts ← TODO: atproto agent helper
677677+apps/api/src/plugins/types.ts ← IntegrationPlugin interface
678678+apps/api/src/plugins/github/ ← REFERENCE: GitHub plugin
679679+apps/api/src/github-integration/ ← REFERENCE: GitHub routes/controllers
680680+apps/api/src/database/schema.ts ← Add tangled_integration table here
681681+apps/api/src/database/relations.ts ← Add relations here
682682+apps/web/src/components/project/github-integration-settings.tsx ← REFERENCE: UI component
683683+i18n/en-US.json ← Add any new UI strings here
684684+```
685685+686686+---
687687+688688+## 8. Useful Commands
689689+690690+```bash
691691+# Start dev environment
692692+pnpm dev
693693+694694+# After schema changes
695695+pnpm --filter @kaneo/api db:generate
696696+# (migrations auto-run on API startup)
697697+698698+# Lint/format (required before commit)
699699+pnpm lint
700700+701701+# Full build (also runs in pre-commit hook)
702702+pnpm build
703703+704704+# Filter to one package
705705+pnpm --filter @kaneo/api dev
706706+pnpm --filter @kaneo/web dev
707707+```
708708+709709+---
710710+711711+## 9. Known Pitfalls & Hard Problems
712712+713713+### 1. Sync loop prevention ⚠️ (highest risk)
714714+715715+When Tangled fires a webhook → Kaneo updates a task → the plugin event system fires → writes back to Tangled → another webhook fires → infinite loop.
716716+717717+The GitHub integration has a guard for this somewhere. **Before writing any inbound webhook handler, find it first.** Grep for `external_source` or look for a flag/context field passed through `publishEvent`. The pattern is likely: if the task update was triggered by an inbound webhook, skip outbound plugin dispatch (or check `external_link` to detect it's already synced).
718718+719719+Mitigation strategies:
720720+721721+- Pass a `syncSource: "tangled"` flag through the event context; have the plugin no-op if it sees its own source
722722+- Use a short-lived in-memory set of "currently syncing" task IDs and skip outbound if the task is in it
723723+- Check the `external_source` field on the activity record before dispatching
724724+725725+**Do not ship inbound sync without this solved.**
726726+727727+---
728728+729729+### 2. atproto session expiry (silent failure risk)
730730+731731+`AtpAgent` sessions expire. In a long-running Hono process, the agent will eventually return auth errors on every call and the integration will silently stop working — no crash, no obvious log.
732732+733733+Plan:
734734+735735+- Wrap every `createRecord`/`updateRecord` call in a helper that catches `401`/`AuthenticationRequired` errors, calls `agent.login()` again, and retries once
736736+- The `@atproto/api` agent has a `session` property and a `resumeSession()` method — read the SDK source before rolling your own
737737+- Consider storing the session in a module-level singleton so re-auth doesn't require a full cold login on every request
738738+739739+---
740740+741741+### 3. `repoAtUri` is hostile UX
742742+743743+Asking a user to paste `at://did:plc:abc123.../sh.tangled.repo/reponame` directly is a bad experience and error-prone. The GitHub integration avoids this with an OAuth install flow.
744744+745745+Options (pick one for the UI):
746746+747747+- Accept a friendly `owner.handle/repo-name` format and resolve it to a DID + AT-URI server-side on save
748748+- Make the settings page do a PDS lookup: given a handle, resolve the DID via `com.atproto.identity.resolveHandle`, then list their `sh.tangled.repo` records, and let the user pick from a dropdown
749749+- Minimum viable: accept the raw AT-URI but show a helper link to tangled.org where they can copy it
750750+751751+The second option is the best UX but requires two extra API calls. Implement the raw paste version first, improve later.
752752+753753+---
754754+755755+### 4. Issue state is a separate record (not a field)
756756+757757+Tangled issue state (`open`/`closed`) is stored as a distinct `sh.tangled.repo.issue.state` record — it is **not** a field on the issue itself. This has two consequences:
758758+759759+- **On import**: to know if an existing Tangled issue is open or closed, you must query `sh.tangled.repo.issue.state` records separately and join them by issue AT-URI. You can't just read the issue record.
760760+- **On status sync**: `handleTaskStatusChanged` must write a new `sh.tangled.repo.issue.state` record, not update the issue record. Multiple state records can exist; the most recent one wins.
761761+762762+The `external_link` table's `metadata` JSON field is a good place to cache the last-known state to avoid extra PDS round-trips on every read.
763763+764764+---
765765+766766+### 5. Local webhook development requires a public URL
767767+768768+Tangled webhooks need an HTTPS URL it can POST to. `localhost:1337` will not work.
769769+770770+You'll need one of:
771771+772772+- `ngrok http 1337` — creates a temporary public tunnel, free tier sufficient for dev
773773+- `cloudflared tunnel` — Cloudflare's equivalent, also free
774774+- Deploy to a staging environment for inbound sync testing
775775+776776+Set this up before starting Phase 5 or you won't be able to test inbound sync at all. Add a note in your `.env.sample`: `WEBHOOK_BASE_URL=https://your-ngrok-url.ngrok.io`.
777777+778778+---
779779+780780+## 10. External References
781781+782782+| Resource | URL |
783783+| ---------------------------- | ---------------------------------------------------------- |
784784+| Kaneo upstream | https://github.com/usekaneo/kaneo |
785785+| Your Tangled fork | https://tangled.org/danieldaum.net/kaneo |
786786+| Tangled docs | https://docs.tangled.org |
787787+| Tangled webhooks | https://docs.tangled.org/webhooks |
788788+| Tangled core monorepo | https://tangled.org/tangled.org/core |
789789+| DeepWiki: GitHub integration | https://deepwiki.com/usekaneo/kaneo/5.4-github-integration |
790790+| Reference MCP impl (Python) | https://tangled.org/zzstoatzz/tangled-mcp |
791791+| @atproto/api npm | https://www.npmjs.com/package/@atproto/api |
792792+| Kaneo Discord | https://discord.gg/rU4tSyhXXU |