syokan
AGENTS.md0 starsChanged 18 days agoMITView on GitHub
syokan (召喚, "summon") is a verb. An LLM speaks a JSON incantation, and a rich, living interface appears — no JSX written, no build step. Typing syokan dashboard.json reads literally as "syokan dashboard.json": the command itself is the incantation. Views are ephemeral — summoned when needed, they fade; nothing is hoarded. Under the hood this is a schema-driven renderer: data pulled from multiple repositories, external APIs, and the filesystem is rendered for humans through predefined React components. LLMs (Claude Code…
- Pipes a download into a shell
- Reads credentials
- Installs packages
- Commits and pushes
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,700 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository8 other files it gives its agents.
- .agents/skills/brand-assets/AGENTS.mdAGENTS.md
- .agents/skills/coding/AGENTS.mdAGENTS.md
- .agents/skills/brand-assets/CLAUDE.mdCLAUDE.md
- .agents/skills/coding/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- brand-assetsSkill
- codingSkill
- syokanSkill
The file
We split this AGENTS.md into the rules it gives an agent
4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 66
If all of these flow into the same JSON envelope at POST /api/snapshots, the renderer stays unchanged no matter how many input paths are …
- Distinctive: line 83
Instead of having the LLM "write JSX", have it output a JSON tree that satisfies a schema.
- Distinctive: line 198
No flattery. Point out problems and risks bluntly
- Distinctive: line 199
Write comments only to explain "why".
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
syokan
syokan (召喚, "summon") is a verb. An LLM speaks a JSON incantation, and a rich, living interface appears — no JSX written, no build step. Typing syokan dashboard.json reads literally as "syokan dashboard.json": the command itself is the incantation. Views are ephemeral — summoned when needed, they fade; nothing is hoarded. Under the hood this is a schema-driven renderer: data pulled from multiple repositories, external APIs, and the filesystem is rendered for humans through predefined React components. LLMs (Claude Code / scheduled agents / CLI) post a JSON tree only — no per-view JSX is ever generated.
Setup and usage live in README.md; the catalog type list and the
POST /api/snapshotsenvelope schema are sourced live fromGET /api/catalog(syokan catalog); CLI commands live insyokan --help(machine-readable via--help --json) — those are the SSOT. This AGENTS.md covers design judgments (why) and development conventions (how to change things).
Repo learning skills
- Brand/logo/OGP changes should load
.agents/skills/brand-assets/for repo-specific asset pitfalls maintained by session-retro. - Code, build, dev-server, and filesystem-watcher changes should load
.agents/skills/coding/for repo-specific pitfalls maintained by session-retro. - Snapshot store / envelope / API compatibility changes should load
.agents/skills/coding/for repo-specific migration pitfalls maintained by session-retro.
Why build this
The problem to solve
The data I want to look at is scattered around me:
- Today's RSS feed (a daily input markdown)
- An in-progress code review (diff fetched via
gh+ my own comments) - Meeting-notes markdown shared at work (an LLM structures it into a catalog tree on the spot)
- Today's Calendar / TODO
- Re-reading an article I saved somewhere
I want to see these at a single URL, in structured UI, only when needed. Opening markdown files one after another doesn't fit the requirement, and neither does hoarding everything in Notion.
The limits of having Claude Code write JSX every time
Building views with Claude Code is possible, but generating JSX each time means:
- Token cost balloons (hundreds of lines of JSX × display frequency)
- Generation speed is slow (seconds to tens of seconds of lag)
- Accuracy wobbles (wrong props, type mismatches, missing imports)
- Design consistency erodes (the same Card looks subtly different from day to day)
- Un-refactorable — each page becomes a snowflake; no shared change is possible
An LLM's real strength is structuring data, not rendering it. Rendering, designed once up front, can be reused. I want to structure that division of labor.
Why separate from the persistence layer
The place where data should be kept and the place where you just want to look at it have different lifecycles:
| Keep (persistence layer) | Just look (syokan) | |
| Example | "a concept I learned 3 months ago" | "today's RSS", "an in-progress review" |
| Lost on deletion | Knowledge | Nothing (reconstructible) |
| Backup | Required | Unnecessary |
| Schema stability | High | Loose |
Mixing these into the same store means:
- Temporary review state pollutes long-term memory
- "Today's RSS" shows up in a search three years later
- Deletion/cleanup policies clash, so you end up carving out a separate table with retention anyway → in which case, separate them from the start
Therefore syokan does not persist data. Anything worth keeping is explicitly promoted to a separate persistence layer (the promotion path).
Why not pin down the interface
Locking the input path to MCP makes CLI, webhooks, and paste second-class citizens. Actual usage:
- Claude Code posts via file-edit-based workflows
- From the CLI, wrap local content (shared meeting notes, etc.) into an envelope on the spot and post it to view
- A scheduled agent may push periodically in the future
- A gh webhook may trigger on PR reviews
If all of these flow into the same JSON envelope at POST /api/snapshots, the renderer stays unchanged no matter how many input paths are added.
Core design
Separation of roles
| Layer | Role | This project |
| Memory layer (for LLMs) | Long-term memory, wiki, knowledge queries | Out of scope (a separate persistence layer owns this) |
| View layer (for humans) | Ephemeral dashboards, in-progress reviews, today's RSS | This (syokan) |
syokan does not persist data (ephemeral). Information that needs long-term storage is explicitly promoted to a separate persistence layer.
Ephemeral in appearance, kept as a record (archive). Once view writeback put human check state into snapshots, a destroying DELETE made sidebar tidying erase "what I looked at and finished that day". So DELETE /api/snapshots/:id archives: the envelope (check state included) is copied to state/archive/<id>.json — one file per id, always overwritten with the latest envelope; archivedAt marks the last archive time, not a generation — and dropped from the active store. The list and view routes treat it exactly like the old delete (SSE delete, view → not-found), but GET /api/snapshots/:id still returns it (active wins over an archive record of the same id; archivedAt is null while active), GET /api/snapshots?archived=1 lists archive records (a query param, because a literal /archived segment would collide with :id), and syokan snapshots list|get|purge is the LLM's entry. Re-posting an archived snapshot's idempotencyKey revives it under the same id/URL with the posted content (the key→id mapping outlives the active entry); the archive record is not consumed, and old checks are never merged into the new tree (that would reintroduce the item-correspondence problem). Physical removal is only POST /api/snapshots/:id/purge (AIP-136 custom method; Bun.serve can't route a :verb suffix) — archive records only, never an active snapshot. Why not export to files: two authorities (file + store) is the split-brain view-writeback removed. Why not a git backend: an external writer bypasses the write lock/CAS merge point; history/backup is a one-way downstream sync of state/archive/ (private remote only — it holds content and check state), not a request-path concern. Archive paths are built from :id, so an id must be a single safe path component ([A-Za-z0-9_-]{1,128}) before it reaches join(). PRD: docs/prd/snapshot-archive/.
Schema-driven view
Instead of having the LLM "write JSX", have it output a JSON tree that satisfies a schema. Zod validates it; the catalog maps it to React components.
{ type: "Heading", props: { text, href } }
↓
catalog["Heading"] → <Heading {...props} />Benefits: fewer LLM tokens, generation speed, type safety, design consistency, easy refactoring.
The catalog's type and props definitions have src/catalogs as the single SSOT. manifest.ts turns them into JSON Schema and exposes them at GET /api/catalog; the LLM (skill) pulls the props contract from this API rather than from a transcription in md. Copying into md drifts on every catalog change, so the SSOT is kept in exactly one place.
When you add / remove / rename a catalog type (or change the envelope, CLI, or a cross-cutting mechanism), update the in-repo skill skills/syokan/ in the same change. Props and the envelope shape are pulled live from GET /api/catalog, so they never need transcribing — but the skill's SKILL.md still hand-carries the prose describing the posting flow and each type's intended use, and that does not auto-follow the catalog. skills/syokan/ is the SSOT of the skill (the dotfiles .agents/skills/ copy is downstream and out of scope here). This is exactly what drifted when markdown support was removed and the primitive types were added.
Catalog admission bar
A new catalog node must buy at least one of: (1) interaction / viewer-local state (checks, folds — see Checklist/Collapsible), (2) validated data semantics (a schema that catches producer mistakes, with the renderer owning presentation consistency), (3) a rendering engine (diff view, mermaid, syntax highlighting), (4) structure consumed by navigation/anchors (id, in-view links). Alias / thin-wrapper nodes that only relabel another node are forbidden — the deleted PlainText node delegated to Code with no lang and bought nothing. Typography/prose belongs to Markdown, not to a new node.
Adding a node is non-breaking; removing one is breaking (consumers already emit it). This asymmetry sets the bar for adding at maximal, and a node whose usefulness is doubtful should be removed early — "might need it later" is not a keep reason; a removed node can always be re-added once an actual need shows up.
Templates — for "making a favorite view reproducible" — follow the same philosophy, with syokan as the SSOT. A template is "a saved envelope as-is"; syokan does not interpret its contents, it only stores and lists them (~/.local/share/syokan/templates/, GET/POST/DELETE /api/templates). There is no placeholder render engine. Assembling from a template (placeholder substitution, expanding variable-length lists) is the LLM's (skill's) job; syokan stays a vault. Unlike snapshots (ephemeral views; deleted ones survive only as archive records), templates are meant to be kept.
Interface-free
The input path is not fixed to any one of MCP / CLI / HTTP / paste. Everything is unified into the same JSON envelope at POST /api/snapshots (the envelope schema's SSOT is the envelope key of GET /api/catalog).
[Claude Code] ──┐
[CLI] ──┤
[scheduled agent] ──┼──→ JSON tree → catalog → React render
[gh webhook] ──┘The store is the view (writeback + change notifications)
Every view renders the stored snapshot — there is no file reference or live-source mode (the TreeDoc file-reference node, /api/files*, and the file-watcher were removed; the driver was "view 上での操作が、データに還らない", docs/prd/view-writeback/). Why not the source file as SSOT: writes from outside syokan (LLM Write, editors, other agents) cannot be serialized, so no single merge point exists for the PATCH CAS — it degrades to silent last-writer-wins. stdin posts have no file at all, so a file-backed mode would recreate two authority kinds. And a file mid-edit is not guaranteed to be a valid catalog tree. file:<abspath> survives only as an idempotency identity; content authority moved to the store. What keeps views live is two write paths plus a notification:
- Full refresh:
PUT /api/snapshotsreplaces root/title in place by idempotencyKey (same id/URL).syokan <tree.json>inlines a bare catalog tree into{ title: basename, root: <tree>, idempotencyKey: "file:<abspath>" }— the snapshot holds the file's content at invocation time; re-running updates the same view, and renaming the file mints a new one. - View writeback:
PATCH /api/snapshots/:idapplies a conditional item-scoped set{ nodeId, item: { label, occurrence }, set, expect }to the node carryingid, inside the same write lock as PUT.itempicks theoccurrenceth (1-based) same-label element of the node'sitems— the node-identity item correspondence, never an index.expectis a prop-level precondition on the node: Checklist sends the rendereditemsarray verbatim (a compare-and-set —nullstands for an absent prop), so a same-label insertion that would silently shiftoccurrenceis refused before the wrong item is rewritten. The mutated node's props must still satisfy its catalog schema — checked node-scoped, not tree-wide: stored trees aren't revalidated on read and may carry since-removed types, which must not freeze writebacks on unrelated nodes. Either way a view cannot corrupt the store. Refusals are409 node_not_found/409 target_not_found(label absent or occurrence out of range) /409 value_conflict(expect no longer matches) /422 invalid_set(schema or prototype-chain keys) — the view reverts its optimistic state and refetches (WritebackTarget.refresh) on any of them, then waits for the user to re-operate. Parallel PATCHes serialize on the write lock, so a second writer sharing anexpectalways loses the CAS; consecutive view writebacks fold queued write values into the nextexpect.items— the items confirmed by the previous write. OnlyChecklistchecks write back today;useWritebackTarget(src/lib/viewState.tsx) is the gate — it yields a target only for an id-carrying node inside a store-backed, non-shared view, so share viewers and template previews can never write. - Change notifications: every active-store mutation (create — revive included — /update/patch/delete=archive; purge touches only the archive and emits nothing) emits
event: change+{ id, kind }over SSE atGET /api/snapshots/changes. Events coalesce over a short window into one invalidation carrying the union of ids; apatchcan't move id/title/createdAt, so it refetches only the view showing that snapshot, while other kinds also re-run the shell list — a delete of the open snapshot drops to notFound. Notifications are in-process only (a second server instance writing the same store file isn't seen); the focus/visibilitychange refetch stays as the floor for that. Content is never pushed over SSE — "changed" only, then a GET — so initial mount and updates share one read path. - Origin guard: every mutation endpoint (snapshots POST/PUT/PATCH/DELETE/purge, templates, settings, probes, publish/auth) rejects a request whose
Origin/Refererauthority differs from the request's own (server/origin.ts) — a page on another origin, another localhost port included, cannot drive writes; requests carrying neither header (CLI/curl) pass.
Input is catalog trees only: markdown is deliberately not rendered — a second expression path would let LLMs bypass structuring and mask catalog gaps (history: .agent/prd/json-only-view/).
Directory structure
syokan/ # bun workspace root (package "syokan-workspace"): delegation scripts only, no deps. tsconfig.base.json is the shared compilerOptions base
├── apps/syokan/ # the app (workspace package "@syokan/app"; version SSOT = its package.json)
│ ├── entry.ts # dual-mode entry for the single binary (SYOKAN_SERVE switches cli/server)
│ ├── cli/syokan.ts # CLI (post / open / stop / catalog / templates). Lazy-spawns the server (compiled: itself)
│ ├── build.ts # compile script (host=dist/syokan; --release cross-compiles each OS/arch)
│ ├── src/
│ │ ├── frontend.tsx # RouterProvider mount
│ │ ├── router.tsx # TanStack Router route tree (root → _shell layout=AppShell → home/view)
│ │ ├── Home.tsx / ViewPage.tsx / Render.tsx # each route's body + recursive JSON tree renderer
│ │ ├── schema/ # Zod schemas (catalog Item / envelope / validation formatting)
│ │ ├── lib/ # cross-cutting utils (paths=XDG 3-way (config/data/state) resolution / cn / date / code / snapshots ...)
│ │ ├── catalogs/ # ★ public types LLMs post as JSON. index.ts is the registry; manifest.ts JSON-Schemas it for GET /api/catalog
│ │ └── components/ # internal UI not registered in the catalog (ui=shadcn / AppShell / AppSidebar / PageLayout ...)
│ ├── server/ # Bun.serve (127.0.0.1 bind). routes.ts=/api/{snapshots(?archived=1),snapshots/:id/purge,snapshots/changes(SSE),catalog,templates,settings}, store.ts=active snapshots (ephemeral) + archive records (state/archive/<id>.json; DELETE archives, purge removes) + patch + change subscribers, origin.ts=foreign-Origin rejection on mutations, readFile.ts=text file reads for Probe, templates.ts=template storage (persistent), setting.ts=display-settings singleton (persistent), share.ts=publish/auth proxy entrypoint (a Hono sub-app mounted in Bun.serve — routing / CSRF guard / request+response shaping; the FE calls it via hc with the exported ShareAppType, so the FE depends only on its own server's contract, never on apps/share directly) with shareService.ts holding the operations (Worker client / auth.json token holder / publish assembly / list·delete proxy, returning domain results), redact.ts=probe redaction at publish
│ └── .storybook/ # visual review base for the catalog
├── apps/share/ # bun workspace package (@syokan/share): the public-share Cloudflare Worker (worker.ts=Hono /api/v1/*, hc<AppType> RPC types), viewer/ (share page + landing SPA), wrangler.jsonc. Deploys independently (`bun run deploy:share`). viewer imports apps/syokan/src/catalogs + Render.tsx directly — a deliberate coupling so catalog and viewer share one deploy unit (no schema drift). Design/history: .agent/prd/public-share/
└── skills/syokan/ # ★ SSOT of the syokan skill for LLMs. Bundled in the repo for distribution; the dotfiles side (.agents/skills/) is an installed copy. Always edit here when fixing the skillClient-side routing uses TanStack Router (code-based routes; no Vite plugin — it rides Bun's bundler). The resident shell is a pathless layout route (_shell, component=AppShell) that mounts the sidebar and content slot exactly once; route transitions swap only the contents of <Outlet />. This keeps the sidebar's open/closed state, scroll position, and the already-fetched list alive across transitions, and the router's scrollRestoration restores the reading position in the body, keyed by pathname (per /snapshots/:id, not per history entry) so revisiting a snapshot restores where you left off. Direct URL / reload / back-forward work via the server's SPA fallback (/* → HTML). Paths matching no route are caught by a splat route (path: "$") under _shell so they render inside the shell (a pathless layout unmatches itself when no child matches, so a root-level splat would lose the shell).
The snapshot list is owned by the _shell layout's loader and is not re-fetched on child transitions (no loading flicker). Three refresh triggers, all via router.invalidate filtered to the relevant match (background revalidation = stale-while-revalidate, so the list never disappears): after an in-app deletion, on a /api/snapshots/changes SSE event (the view match only when its own id changed; _shell only for non-patch kinds, which can't move list fields), and on returning to the tab (focus / visibilitychange) as the floor for mutations the in-process SSE cannot see. Creation happens outside the app (CLI / LLM), so there is no in-app trigger; the SSE carries it to open windows in the same process. If the list fetch fails, the whole shell falls to the errorComponent (rather than leaving only the body, errors are surfaced together at the point of interaction — a deliberate trade-off).
The original MVP used full page reloads, premised on "no state carried between pages". Adding stateful chrome (the sidebar) broke that premise, so we deliberately switched to client routing (a reversal of the old "not adopting it" policy; history in
.agent/prd/client-routing/).
Component collocation
Implement a component at <Name>/index.tsx and colocate its test (<Name>.test.tsx) and story (<Name>.stories.tsx) in the same dir. Imports point at the dir with a relative path, like ../PageLayout (resolves to index.tsx). No @/ alias — the share package pulls syokan sources across the workspace boundary, and a root-level alias made that resolution setup-dependent (tsconfig paths / Vite alias / Bun all had to agree), so imports are relative only (sole exception: components/ui/, below). Gathering related files in one place prevents stragglers during refactors/deletions.
- Catalog-public (types LLMs post as JSON) go in
catalogs/<Name>/; non-public internal parts go incomponents/<Name>/ - Internal-only subparts (used solely by that component) nest under the parent dir (e.g. the catalog's
Code/CopyButton/). Once shared by multiple components, promote it one level up. Scope = location components/ui/is the exception: shadcn's generated flat files (not dir-ified), and the one zone using#subpath imports (#lib/utils.tsetc.). components.json aliases point at package.jsonimportsentries — officially supported by the shadcn CLI, soaddoutput lands in the right place with working imports, untouched.#is Node-standard and resolves against this package's own package.json, so it has none of the cross-package ambiguity that got the@/alias removed. (Relative aliases in components.json don't work: the alias string doubles as the verbatim import specifier in generated files, and a placement-correct value is import-broken and vice versa)
Setup / usage
Setup steps (mise / bun / portless) have README.md as SSOT. The catalog type list and the POST /api/snapshots envelope schema are sourced live from GET /api/catalog (syokan catalog) — transcribing into md drifts, so neither the README nor this file carries them. CLI commands, in the same spirit, have syokan --help (machine-readable via --help --json) as SSOT. To avoid duplication, none of it is written here.
Storybook is the visual review base for catalog components. <Name>/<Name>.stories.tsx enumerates prop variants, edge cases, and dark/light; .storybook/preview.tsx loads src/styles.css. The toolbar's .dark toggle verifies theme adherence. storybook is registered in .claude/launch.json, so it can also be started via preview. See the README for the launch command.
Tech stack
- Runtime / bundler / HMR / TS execution: Bun (consolidated into one; no Vite / Hono / React Router)
- Frontend: React + TypeScript (bundled via Bun's HTML import + native JSX/TSX)
- UI: shadcn/ui (
base-novastyle) +@base-ui/react+ Tailwind CSS v4 - Backend:
Bun.serve({ routes })— the frontend is served via theindex.htmlimport (dev: on-the-fly bundle + HMR; compile: embedded in the binary)./api/*cohabits in the same process - Validation: Zod
- Routing (server):
Bun.serveroutes patterns (/api/snapshotshandler +/*SPA fallback) - Routing (client): TanStack Router (code-based routes, no Vite plugin). Chosen for meeting the loader / scrollRestoration / pending, notFound, error / resident-layout requirements
- Component catalog: Storybook (
@storybook/react-vite+@tailwindcss/vite). Catalog components become stories for visual review. The builder is Vite, but it is a Storybook-only devDep; the app's "no Vite" policy applies only to the app bundle (there is no Bun-native Storybook builder, so no alternative exists)
Distribution (compile / dev-global separation)
The global tool is a single executable (bun run compile → apps/syokan/dist/syokan). It runs without bun/node/npm. The split "see the latest while developing / use the pinned build day-to-day" is achieved not at the CLI or server level but by execution form (binary vs bun run dev).
- Dual-mode entry: after compilation, cli + server + frontend live in one binary. entry.ts branches CLI / server on
SYOKAN_SERVE. Lazy-spawn works as: "for the single binary, re-exec itself (process.execPath) withSYOKAN_SERVE=1; in dev, runbun apps/syokan/server/index.ts". The dev/compiled distinction is made byisCompiledBinary()(whether execPath's basename isbun). CLI args areargv.slice(2)in both modes (compiled builds also put the embedded entry in argv[1]). - The port is the only record of a running server; there is no pidfile.
GET /api/healthreturnsversion+pid, which is the whole contract: whatever answers it is the server (adopt it, whoever started it), and thepidit reports is whatstopsignals. A file recording "the pid we started" was the previous design and it went stale the moment a server died or was started by hand — the CLI then spawned a rival that could never bind the occupied port, timed out after 15s on every call, and left the failed child running, so an agent looping over the CLI piled up servers. Corollaries: a readiness timeout reaps its own child (through the Bun child handle, never a bare pid, which may have been recycled), and a port that answers without that contract is reported as a port conflict rather than a readiness timeout — waiting cannot help when the child can never take the port. Nothing here is backward compatible with a server that predates thepidfield; such a server reads as foreign. - The frontend is a static
import index from "../index.html". Dev gets an on-the-fly bundle + HMR; at compile time Bun bundles the frontend and embeds it in the binary (the same static import serves both). @tanstack/router-coreis patched viabun patch(patches/,patchedDependenciesin the root package.json) to defer one module-evaluation read inside itsrouter.js<->load-client.jsimport cycle, because Bun's HMR runtime links modules in one phase and handsnullto one side of a cycle (oven-sh/bun#40248, fix oven-sh/bun#40259 unreleased as of bun 1.4.2) — without it the dev page is blank. Disabling HMR was rejected (pays for a Bun bug with the whole dev loop) and pinning the router was rejected (freezes every router bump). bun drops a patch silently when thename@versionkey stops matching, soapps/syokan/server/routerCorePatch.test.tsguards it: on a router-core bump runbun run patch:router-core(apps/syokan/scripts/patch-router-core.tsre-applies the wrap, commits the patch, and cleans stale keys/files); once the pinned bun ships the fix, delete patch + entry + test.- tailwind / bun-plugin-tailwind are devDeps.
bun build --compile(the CLI) cannot take plugins, sobuild.tswires them explicitly viaBun.build({ compile, plugins:[tailwind] })to expand CSS at compile time. - dev / global separation: global =
5773(the SSOT issrc/lib/port.ts, shared by the server's bind and the CLI's base URL); dev =5873with the repo-local.syokan-dev/(gitignored). The ports differ, so the two lazy-spawned servers never collide. Both stay clear of vite's5173/4173and the5174+walk it takes when occupied: the global server is long-lived, so squatting a port frontend dev servers reach for first would silently push every other project onto a fallback port (this is why the original5173/5273pair was abandoned). Persistence locations are consolidated insrc/lib/paths.tsand split across XDG base directories by lifecycle: settings (keep; tracked in dotfiles) at~/.config/syokan/settings.json; templates (keep; user data) at~/.local/share/syokan/templates/; snapshots + the server log (machine-local; survive restarts but need no backup) at~/.local/state/syokan/; the snapshot archive at~/.local/state/syokan/archive/stays machine-local state too, but it is the only copy of archived content and check state, so it is worth backing up — via a downstream, one-way git sync to a private remote, never by making git the store. Everything used to be consolidated under config, but machine-local data mixed into the dotfiles-tracked tree (~/.config) invited accidentally committing it to git, so they were separated (respectingXDG_{CONFIG,DATA,STATE}_HOME). Snapshots go in state, not cache: cache's contract is "a third party may purge without notice", but syokan does not regenerate snapshots automatically (the producer must re-post), so losing them mid-use is unrecoverable. Location overrides carry no bespokeSYOKAN_*envs — they are consolidated onXDG_*_HOMEalone. In dev, thedevscript inpackage.jsonpointsXDG_{CONFIG,DATA,STATE}_HOMEat$PWD/.syokan-dev/{config,data,state}so global settings stay clean. XDG envs affect the whole process, so they are scoped viaenvto the server process (bun) only, to avoid dragging in other pipeline tools likeportless. To check renderer changes against a local snapshot, post to the dev server withsyokan snapshot.json: inside this repo the mise[shell_alias]remapssyokanto the local-source CLI pinned toSYOKAN_BASE_URL=http://localhost:5873(= the dev server), so in-reposyokannever posts to the production instance by accident. Outside the repo the alias is unset andsyokanis the global binary (= 5773, production). Trade-off: while cd'd in the repo you can't reach the production instance via baresyokan(rare; production use happens outside the repo). - Gatekeeper may block unsigned binaries on macOS. Local builds run as-is, but if a distributed/copied binary is rejected:
codesign --sign - apps/syokan/dist/syokan. - Distribution:
bun run compile:all(=build.ts --release) cross-compiles each OS/arch and emitsapps/syokan/dist/syokan-<os>-<arch>(names in a form mise'sgithubbackend can use to detect OS/arch). Cross-compilation downloads the target bun runtime each time. Pushing av*tag runs the whole pipeline inrelease.yml: build → smoke → publish → homebrew. The version bump and the tag are separate steps, deliberately. main is guarded by themain-required-checksruleset (required status checks, no bypass actors), so a bump commit pushed straight to main is rejected with GH013 — the bump rides an ordinary PR like any other change.bun run release(scripts/release.ts) therefore does only what a ruleset cannot block: on a clean main in sync with origin, it tags the merged bump commit withv<package.json version>and pushes that tag ref alone. The script used to bump + commit +git push --follow-tagsin one go, which failed at the push every time and left a stranded bump commit locally. Publishing is gated on the smoke test so a broken artifact never ships, and the assets carry achecksums.txtthat both downstream install paths verify against. The three install paths arebrew install wwwyo/tap/syokan(formula regenerated per release byscripts/generate-formula.tsand pushed to the separate wwwyo/homebrew-tap repo — a one-directional flow, so the formula is never hand-edited there),curl … | sh(install.sh, served from the repo's default branch so it updates independently of releases), and the pre-existingmise use -g github:wwwyo/syokan@latest. The tap push needs theTAP_GITHUB_TOKENsecret (fine-grained PAT scoped to that one repo); it runs after publish so a tap failure shows up red without holding back the release. - Smoke test as the release gate:
scripts/smoke.tsdrives a compiled binary end to end (post → view → re-post a bare tree file for an in-place PUT → receive the snapshot SSE change → refetch → PATCH writeback → delete=archive (list/get,syokan snapshots) → revive by re-posting the file → purge (record gone, active untouched) → cross-origin rejection → stop) on both ubuntu and macos runners. It exists becausebun testruns in dev mode and therefore never exercises lazy-spawn (re-exec of the binary itself), the embedded frontend, or the real SSE/write-lock path. Keep it dependency-free and self-isolating (temp XDG dirs + an OS-assigned port) so it can never touch a real install.
Communication policy
- Docs and product copy are English-first (Japanese versions live in
*.ja.md, e.g.README.ja.md). Use "syokan" as a bare transitive verb in both languages — EN "syokan your notes", JA 「今日の RSS を syokan」 — never "do syokan" / 「syokan する」; "syokan" stands on its own - No flattery. Point out problems and risks bluntly
- Write comments only to explain "why". Code comments (and JSDoc) are written in English
- Uphold the "no data persistence" principle. When persistence becomes tempting, think about a promotion path to a separate persistence layer
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

