Skip to content

Dashboard UX plan

Status: refined through a grilling session (2026-09-23). The raw ideas and observations from HITL testing of main at d45a21e are kept as the record, each with the tensions that were raised. The Decisions section holds what Nathan confirmed; Open items lists what is still to verify; Proposed slices is a proposal. Next: turn the slices into issues.

Scope: scripts/dashboard_static/ (index.html, dashboard.js, dashboard.css).

Observations

Header health dots are two-state

The upper-right dots render ok === true as green and everything else as .off (yellow). "Never run", "unknown" and "failed" are indistinguishable.

The ring's centre panel already distinguishes ok | fail | unknown (statusColor() / statusPill()), so the header and the ring disagree about what the same probe means.

Related, already fixed in !32: the header was drawn once at page load and never refreshed after Run checks. The header snapshot (/api/state) and the ring health (/api/health) are still two sources for the same facts.

Open questions:

  • Should the header consume /api/health (one source) instead of /api/state?
  • Does "check never run this session" deserve its own visual state, distinct from a failure?
  • Should the header refresh on an interval, or only on user actions?

Ideas

Projects: replace Workspaces / Labs / Sandbox with one CRUD surface

Raw thought (Nathan): "Sandbox and Labs" looks inert and under-instrumented; the Labs page is a placeholder. Re-imagine from the ground up as a unified interface to view, create and manage (CRUD) the projects under workspace/projects, and connect each one to the other tool-chest tools and services. "Project" here means the interchangeable unit that connections like "Claude (project)" attach to: something like a virtual environment, namespace or git repo.

What the code does today (verified 2026-09-23):

  • The Work nav group has three separate leaves: Workspaces ("Hybrid folders"), Labs ("Read/analyze trees"), Sandbox ("Plan only").
  • "Workspaces" are data_root/<domain>/<slug> folders and "labs" are data_root/lab/* (dashboard_state.py). They are data folders, not the repos under workspace/projects/. The proposed feature is a different object.
  • Sandbox only fetches /api/sandbox?image= and prints a plan. It never runs anything, which matches the "inert" impression.

Tensions to resolve in the grilling session (not decisions):

  • Three meanings of "workspace". The ~/Documents/workspace root with its six buckets; the tenancy data-root folders; and now the proposed project. The UI should probably not reuse the word for the new thing.
  • Overlap with existing layout tooling. workspace.toml plus make layout-check/plan/apply already models buckets and moves repos safely. A CRUD UI either wraps that or competes with it.
  • Move/rename/delete are not plain CRUD. A bare mv orphans ~/.claude/projects/<path-key> memory, ~/.claude.json entries, worktrees and MCP paths, and layout-apply wants Claude Code and Cursor closed. A dashboard running inside that environment cannot do this safely while they are open.
  • Buckets have different rules. oss/ is read-only, work/ is confidential, projects/ is read/write. Does the UI cover only projects/, or every bucket with per-bucket permissions?
  • What is a "connection"? (see the model below)

Agreed so far (Nathan, 2026-09-23): the UI says "project", not "workspace"; the manifest (workspace.toml) is the single source of truth and the UI sits on top.

The model: project × service × agent client

  • A service (code-library, research, voice, PKM, ...) is an optional add-on to any project.
  • A connection is an agent client profile that a service is enabled for on that project. Nathan mainly uses Claude Code, with Cursor or Cline as fallbacks. Example: on a new project, enable Voice and PKM for Claude Code (project) and leave the rest off until needed.

Ground truth (verified 2026-09-23): this matrix already exists for one project. tool-chest.toml declares [client.*] (claude-project, claude-user, cursor, cursor-user) and each [mcp.*] lists its clients; gen_mcp_config.py generates the files and make mcp-check catches drift. But @root is tool-chest itself, so today "Claude Code (project)" means tool-chest's own .mcp.json. The feature is largely making the project a variable.

Tensions to resolve in the grilling session (not decisions):

  • Not every service is an MCP server. Only MCP servers fit the existing generator. Voice is a make target and PKM capture is a skill under ~/.claude/skills. "Enable Voice for Claude Code (project)" needs a defined meaning per mechanism: MCP entry, project skill, rule file, or nothing.
  • Not every client has a project scope. Cline's MCP settings are, as far as I know, global (VS Code extension storage), so "Cline (project)" may not exist for MCP. Unverified: check the primary docs before it lands in an issue.
  • Where does per-project enablement live? workspace.toml describes layout; enablement is a different concern. Extend the manifest, add a [project.<name>] section to tool-chest.toml, or a new file? Both need a generator plus a drift check (global rule), not a "keep in sync" comment.
  • The UI writes into other repos. A project's .mcp.json would be generated from tool-chest and committed to that repo. Is that acceptable for every project? For contrib/ repos it is not.
  • Absolute paths and secrets. Generated entries point at tool-chest's absolute paths; secrets must stay @env: references.
  • Claude Code prompts to approve project .mcp.json servers on first use, so "enabled" in the UI and "active" in the client can differ. Same class of problem as the existing "restart pending" indicator.

Capture/Inbox: file attachments

Raw thought (Nathan): Capture should accept file attachments as a valid URL or file path, with drag-and-drop and an interactive file browser. Files are copied to an internal data dir for the project. Motivating case: starting a new work project, write notes, attach the Scope of Work (SOW), file both in AnyType under "Work", then use the patterns and knowledge services to find relevant prior art.

What the code does today (verified 2026-09-23):

  • Capture is text only. prepare_capture() takes a content string and submit_capture() creates one inbox object. Multipart parsing exists (parse_multipart, used by voice upload), so the transport is partly there.
  • The work space is agent_read: true, agent_write: "approval", so a confirm gate already applies to work captures.
  • rag_deny_globs = ["lab/**", "projects/work/**"] (relative to data_root) keeps RAG/MCP indexes away from confidential data.
  • patterns is a committed, anonymized, in-repo corpus, and it refuses paths matching the deny globs. It does not index attachments.

Tensions to resolve in the grilling session (not decisions):

  • A file picker and drag-and-drop yield bytes, not paths. Browsers hide the real path. So there are really two inputs: upload bytes (picker, drop) and server-side read of a typed path or URL. The server reading an arbitrary path or fetching an arbitrary URL needs limits (http/https only, no private addresses, size cap, symlinks).
  • Two copies of a confidential document. One in the project data dir, one in AnyType. Which is canonical, and does capture create both atomically or can one fail?
  • Where the copy lands decides whether it is indexed. It must match rag_deny_globs for work material. data_root/projects/work/** uses "projects" in the old data-folder sense, not the manifest projects above: a third naming collision to clean up.
  • "Prior art" is not a service that reads the SOW. patterns searches its own corpus. The step is: derive a query from the notes/SOW text, then search. Who derives it (dashboard button, omnibox, or the agent in Claude Code)? If an LLM does, confidential text goes to a cloud model, which is a work/ confidentiality decision, not a default.
  • Text extraction. A SOW is likely PDF or DOCX. Neither AnyType attachment nor lexical search sees inside it without extraction, which adds a dependency.
  • Which service is "knowledge"? Unclear: the agent-wiki, the AnyType knowledge base, or the library.
  • AnyType file upload is unverified against the local API version in use.
  • Ties to the Projects idea: "for a given project" needs the project registry to exist first.

People / local-agent: is it worth keeping?

Decided 2026-09-23 (Nathan): remove it. AnyType superseded it, and tracking colleague relationships is no longer needed. Removal is on branch chore/remove-local-agent (tag local-agent-v0 keeps it recoverable). The review below is kept as the record.

Raw thought (Nathan): the People page is a stub, local-agent/ is a rough sketch from long ago, and it may not be worth keeping. Task: critically review what is actually there. Review done 2026-09-23.

What exists (verified):

  • A local-first "org-navigator": SQLite store (people, edges, notes, and a reserved embeddings table) with a la CLI. About 1.7k lines of Python and 50 passing tests. The code is competent.
  • Commands: person, link, note (with owe/owed commitments), and queries who TEAM (1st/2nd-degree contacts), intro (warm-intro route), brief (dossier), owe, owed. Plus a chat-capture mode (Claude Code driving a local Ollama model), a shared skill, and an egress audit script.
  • The dashboard page is a stub: static text saying the read-only view "is P1". The only code coupling is dashboard.py reading local_agent.ollama for the Ollama health dot.

Evidence of use:

  • The live store has 1 person, 0 links, 1 note, 0 embeddings. Last write was 2026-06-12, the day after the initial commit. The README's "slice 4: actually use it and find the friction" never happened. Real development ended 2026-07-07; since then only a public-repo cleanup touched it.
  • embeddings is never populated. who_depth in tool-chest.toml is documented as unused.
  • The egress attestation is PARTIAL: layer A passed, layers B (deny-egress functional proof) and C (recorded connections) were SKIPPED, and the log has one row. The published "Security Attestation" page therefore claims more than was demonstrated.

Overlap: AnyType already has a Person type, and pkm-capture already creates type_key: person objects. What local-agent has that AnyType does not is graph traversal (who, intro) and an owe/owed commitments ledger.

Carrying cost: 22 tracked files, 4 pages in the public docs nav, a shared skill (local-agent-capture), a [local_agent] config section, and about 150 lines of the setup skill.

Recommendation (accepted): do not keep it as a tool-chest capability. 1. Tag the current state (local-agent-v0) so it is recoverable, then remove the directory, its docs-nav pages, the shared skill, the setup-skill section and the People nav leaf. 2. Move the Ollama base URL config out of [local_agent] first, since the dashboard health probe and the library/research setup depend on it. 3. Leave ~/.local/share/local-agent/store.db untouched; it is outside the repo. 4. Keep the two ideas worth keeping as backlog notes, not code: a commitments ledger (owe/owed) and warm-intro routing, both probably better built as AnyType views or relations than as a second store.

Open questions for the grilling session:

  • Is the colleague-relationship use case still wanted at all?
  • If wanted, is AnyType the right home, given the privacy contract (nothing leaves the machine) that local-agent was built around?
  • Should the public "Security Attestation" page be removed regardless?

Search: results must be reachable

Raw thought (Nathan): search results are truncated text on the results page and cannot be clicked through. This is a critically important feature in search.

What the code does today (verified 2026-09-23):

  • Hits render as a plain <li>: corpus, title, and the first 200 characters of excerpt (dashboard.js, search-form handler). No link, no detail view.
  • The excerpt is not the matching passage. _lexical_walk sets excerpt = text.strip()[:240], the start of the file, whatever matched. A hit can show a frontmatter block that says nothing about why it matched.
  • Scoring is a raw substring count of query tokens, top 5 per corpus.
  • There is no endpoint that returns a document. The server exposes /api/search and lists, but nothing that reads one hit back.
  • path is inconsistent across corpora: absolute for wiki and patterns, a repo-relative relpath for code-library (meaningless without the repo).
  • Two of the five corpus chips, library and research, return a placeholder hit ("MCP not loaded in this process"). They can never produce a real result in the dashboard.
  • Searchable content is markdown under knowledge/agent-wiki and knowledge/patterns plus the code-library. AnyType is not searched.

Tensions to resolve in the grilling session (not decisions):

  • What does "reachable" mean per corpus? An in-dashboard document viewer, an "open in editor" link, an anytype:// deep link, or a GitHub/GitLab URL? Probably different answers for wiki, patterns, code-library and AnyType.
  • A read-a-file endpoint is a security surface. It must be allow-listed to the corpus roots (rag_deny_globs still applies), with traversal and symlink handling. static_bytes already has a traversal guard test to copy from.
  • Excerpts need to show the match, ideally a window around the first hit with the tokens highlighted, before links matter as much.
  • Fix or remove the dead chips? Wiring library and research needs their MCP servers reachable from the dashboard process, or a direct call into their code.
  • Stable identity for a hit. A link needs an id that survives refactors and re-indexing, not an absolute path.
  • Ties to the file-attachment idea: attachments should be searchable and reachable by the same mechanism.
  • Ties to the Task Browser: tasks are AnyType objects, so "reachable" for a task hit means opening or deep-linking that object, not a file.

Task Browser

Raw thought (Nathan): the Tasks leaf is nearly useless. There is no way to see existing tasks or to confirm that an added task exists. Consider building a task system from scratch versus integrating an open-source tool. Either way, the goal of tool-chest is to reduce the cognitive overhead of context switching, so a minimally featured but well-integrated task manager beats a full-featured but unintegrated one.

Use case: start a new project; attach the SOW and notes to a PKM object for the project; create Person objects for the parties; then create tasks such as "review and sign contract" with optional Deadline and Recipient. Once created, the task is available over MCP to any agent session in the active workspace, so an agent can track time remaining, discuss open tasks, produce work product for a task, and mark it complete.

What the code does today (verified 2026-09-23, against the live Anytype API):

  • Tasks are already AnyType objects. submit_task() creates type: task with done: false and data_classification; list_open_tasks() searches for type: task, filter: "done = false". So the store and the MCP path already exist: the AnyType MCP exposes create, search and patch, and it is connected for Claude Code (project) and Cursor.
  • Listing works against the live API. The agent space returned its 1 task and the work space 0, out of 23 and 1 objects respectively. So "can't tell if it exists" is a UI gap, not a broken call. Note the work space holds a single collection object: the space the SOW use case targets is nearly empty.
  • The browser shows names only. Search results carry id, name and type and no properties, so done state, deadline and recipient are not visible without a second fetch per object.
  • The Tasks page has a space selector, but /api/tasks defaults to agent. A task created in one space is listed only if you refresh that same space.
  • No Deadline or Recipient fields exist in the create form or the payload. Whether the AnyType task type has properties for them was not confirmable with the scoped key (the type listing returned no properties).
  • Verified on a throwaway task (created in the agent space and deleted afterwards, 2026-09-23):
  • done = false is honoured: the task listed as open, disappeared from the filtered list once marked done, and stayed in the unfiltered list.
  • The dashboard's create payload stores done and data_classification correctly (it was read back).
  • Updating uses ops, not a properties body. PATCH .../objects/{id} with {"properties": ...} returns 400; the working body is {"ops":[{"op":"set_properties","set":{"done":true}}]}. The op schema is served by the API itself at GET /v2/schemas/ops/set_properties. This is the call "mark it complete" needs, from the dashboard or from an agent.
  • Deadline needs no schema work. A built-in due_date (date) property exists and accepted "2026-10-01" (stored as 2026-10-01T00:00:00Z).
  • Recipient has no property. The space has assignee (objects), linked_projects, related_projects and status (select), but nothing named recipient. assignee is who does the work, which is not who receives it. Adding a relation would change the PKM schema, so it was not tried.
  • Dead end noted: the documented-looking types: ["task"] search shape returns HTTP 400 on this API version. The dashboard's type shape is the working one.

Reframe (mine): the choice is not "build versus integrate". There is a third option, already half-built: AnyType is the task store, and the work is a better browser, richer create form (Deadline, Recipient linked to a Person object) and an agent-facing convention. It is minimal, integrated, and needs no second system. Whether that suffices depends on the open questions below.

Tensions to resolve in the grilling session (not decisions):

  • Is AnyType tasks enough, or is an external tool (Taskwarrior, Vikunja, Todo.txt, etc.) warranted? Candidate tools are named from memory and unverified: check each one's primary docs (license, API, MCP support) before it appears in an issue.
  • Recipient. Deadline is solved (due_date). For Recipient, is a new relation property to Person objects warranted, or is assignee close enough, or free text in the body? Adding a property is a schema change, and who makes it?
  • Project linkage. A task belongs to a project (see Projects, above). How is that relation stored, and how does "any agent session in the active workspace" resolve to the right set of tasks?
  • Agent write policy. The work space is agent_write: "approval". Should an agent completing a task or creating a follow-up need a confirm every time?
  • What "time remaining" means. Computed in the dashboard, by the agent on request, or a stored reminder? Notifications are a separate, larger decision.
  • Search returns no properties, so a browser showing deadline and done state needs a per-object GET or a different query. Cost and caching?
  • Ties to the file-attachment idea: the SOW example creates the project object, the attachment and the tasks in one flow.

Raw thought (Nathan): Operate/OmniRoute should link out to the OmniRoute dashboard on localhost when it is operational. Minor, but OmniRoute already has an extensive interface, so link to it rather than duplicate its functionality.

What the code does today (verified 2026-09-23):

  • The page shows base_url, enabled/disabled, up/down, and a toggle button. It has no link.
  • OmniRoute is running locally. GET / redirects to /dashboard, so the dashboard is reachable at base_url itself.
  • health_path = "/home" redirects to /login. The probe counts any HTTP response, including a redirect or an error, as "up", so it proves the port answers, not that the dashboard is usable.
  • "Enabled" (tool-chest routes traffic through it) and "healthy" (the process answers) are different states and the page shows both.
  • The header dot and this page are drawn once at page load, the same stale snapshot problem as the check dot fixed in !32. A link shown as "up" can go stale.

Open questions:

  • Link target: base_url, or a configured dashboard_url, so the API address and the UI address can differ?
  • When down, show a disabled link with the reason, or hide it?
  • Open in a new tab (noopener)? The URL comes from config and must be limited to http(s).
  • Should the page show live health (re-probe on view) rather than the boot snapshot?
  • The OmniRoute UI is behind a login. Does the link need to explain that, or can tool-chest ignore it?

Decisions

Made in the grilling session (2026-09-23). Each is confirmed by Nathan.

Projects

  • P1. Source of truth for "which projects exist" is the filesystem plus a per-project config section (hybrid). A project is a directory under workspace/projects/. workspace.toml stays layout-only: it is a record of moves, not a registry, and a new project needs no manifest entry. Connection state is a [project.<name>] section keyed by directory name (no separate stable id), only for projects that have opted in. A rename orphans the entry; the drift check must catch it. This corrects the earlier "manifest as single source of truth" agreement, which rested on my wrong claim that the manifest enumerates repos.
  • Amended (P1a): that state lives in ~/tool-chest-data/projects.toml (under workspace.data_root), not in tool-chest.toml. tool-chest.toml is tracked in a public repo, and project names, AnyType space ids and object ids would publish work or client details. [client.*] and [mcp.*] definitions stay in tool-chest.toml because they are not sensitive. CI has no projects.toml, so make projects-check runs locally and must skip cleanly when the file is absent; it is not a CI gate.

  • P2. Enablement writes no files into other people's repos. Claude Code uses local scope (per project path in ~/.claude.json, via claude mcp add --scope local; the three scopes local/user/project were confirmed from claude mcp add --help). Cursor writes .cursor/mcp.json only in projects/ repos, excluded through .git/info/exclude so it never appears as a change. contrib/ gets Claude local scope only, if at all. oss/ and work/ are never touched. The claude-cli adapter in gen_mcp_config.py is the pattern to extend for a local-scope variant.

  • P3. A "service" is exactly one of tool-chest's MCP servers. Today that is anytype, library, research, patterns and code_library. The per-project matrix is MCP server x agent client. Voice is a dashboard feature with no agent tool surface, so it is not toggleable; skills and rules stay user-level. library, research and patterns are user-scope today and show as "on (global)", read-only, because per-project disabling of a user-scope server is not a mechanism we can point to. anytype and code_library are the toggleable ones. "PKM" in the original example means the anytype MCP.

  • P4. v1 of the Projects page supports List, Create and Update only. List shows directories under projects/ with git branch, dirty state, remote and per-client service state. Create is thin: a directory name, optional git init, and optional service enablement; it does not clone or apply templates. Update toggles a service for a client. Rename, move and delete are not in the UI: the page shows the exact command (make layout-plan) and a warning that Claude Code and Cursor must be closed; delete is never run from the dashboard.

  • Create does not set the git origin (accepted 2026-09-23). It runs mkdir and an optional git init; the remote is added in the terminal when the hosted repo is created.

  • P5. A project directory relates to its AnyType project object by name. The object type is the built-in project (present in both the agent and work spaces; pkm_project exists only in agent and nothing is in use yet, so it is not adopted). The object is named exactly the directory name (the P1 key) and lives in the space matching the project's domain (a work project goes in Work). An agent resolves "this project" by searching for a project object named after its working directory, using only the AnyType MCP; it cannot be assumed to read projects.toml. projects.toml caches the space and object id for the dashboard, and the dashboard re-resolves by name when the cache is missing or stale. The object is created on demand, never automatically. Tasks link to it through a relation (linked_projects or related_projects).

  • Verify before an issue is written: that either relation is settable on the task type, against the live API on a throwaway object.
  • [project.<name>] also needs a domain field (see attachments, below).

  • P6. Add a person type to the Work space, as part of this plan. The work space has no person type today (the agent space does), and the new-project flow creates Person objects for the parties involved. This is a change to the live PKM schema, so it needs an explicit approval step and must not be done as a side effect of another change; the work space is agent_write: "approval".

  • Required tests (Nathan): data remains gitignored, uncommitted and private. Proposed shape, to refine before it becomes an issue:
    1. projects.toml and the data root resolve outside the repo; fail if workspace.data_root is configured inside it.
    2. A canary check reads the local projects.toml, if present, and fails if any project name, space id or object id appears in any tracked file (git ls-files). It skips cleanly when the file is absent, e.g. in CI.
    3. Nothing the dashboard captures (notes, attachments, person data) is written to a path inside the repo; capture code may write only to data_root or to AnyType.
    4. .gitignore covers any in-repo fallback location, and a test asserts it. Calibrate each test on a known-bad case first (a deliberately leaked string must make it fail) so it is not asserting nothing.

Capture attachments

  • A1. The local copy is canonical; AnyType holds a reference only. Capture writes the file under the data root first, in a per-project directory that matches rag_deny_globs for work material (data_root/projects/<domain>/<name>/attachments/), records its SHA-256, and only then creates the AnyType object with the notes and the reference. If the AnyType step fails the file is not lost: capture reports partial success and a retry is idempotent because the hash identifies the file. All local consumers (text extraction, prior-art search) read the canonical copy. AnyType does not hold the file, so the AnyType object is not self-sufficient on a machine without the data dir; that is accepted.
  • The reference should be a path relative to data_root plus the hash, not an absolute path. To settle when the issue is written.

  • A2. Attachment inputs ship in three slices. (1) Drag-and-drop and the file picker (one mechanism: the browser uploads bytes; the multipart transport used by voice already exists); (2) a server-side file path, chosen through an allow-listed browser modelled on the existing /api/fs/browse; (3) URL fetch, later. URL is not needed for v1. It is the riskiest input (server-side request forgery against localhost services such as the AnyType API and OmniRoute, or cloud metadata addresses, including via redirects), so when it is built it needs http/https only, address resolution that refuses loopback, private and link-local ranges (re-checked after every redirect), a size cap and a timeout. Every input has a size cap and lands in the canonical copy (A1).

  • A3. Path attachments use their own explicit allow-list. Separate from the broad /api/fs/browse allow-list (home, data root, workspace, minus a deny-list). Defaults: ~/Downloads, ~/Desktop, ~/Documents, configurable in tool-chest.toml under [capture] (the list is not sensitive). Always denied even inside an allowed root: any dot-directory component and the existing DENIED_DIR_NAMES; symlinks are resolved before the check. The data root is excluded as a source (no copy loop, no pulling one project's attachments into another). The drag-and-drop and picker slice has no path restriction because the user chooses the file. A file from workspace/work/ is allowed but requires an explicit confirm click, consistent with the existing confirm for writes to the Work space, and is marked as coming from work/.

  • A4. Prior-art search is thin in v1, and enriched later. The query is built from the user's own title and notes, not the SOW body (text extraction is deferred). It is a local lexical search over patterns and agent-wiki, with no LLM in the loop, so nothing confidential leaves the machine. The results view must show which corpora were searched and how many documents each holds, so "0 results, corpus empty" is distinguishable from "0 results, no match". Reality today: knowledge/patterns/ holds 0 patterns (only README and TEMPLATE) and agent-wiki holds 22 pages, so the feature's value depends on the corpus being filled.

  • Intended corpus (Nathan): solutions developed for employers or clients, plus design patterns found in the wild (open source codebases) or published as official guidance (AWS and GCP cloud architecture best practices; OPEA.dev's cloud-agnostic open source patterns).
  • The dead library and research chips are a separate item (see Search).

  • A5. Patterns live in two tiers, private by default. Cautious approach (Nathan): omit anything potentially sensitive or copyrighted.

  • Private tier: a directory under a location Nathan controls, outside the repo and not under rag_deny_globs (for example ~/tool-chest-data/patterns/). Every pattern starts here: employer-derived solutions, vendor documentation excerpts, anything with a licensing question. Never committed.
  • Public tier: the existing knowledge/patterns/, only for patterns that are Nathan's own or clearly reusable, after anonymizing and a license check. Moving a pattern from private to public is an explicit act (the existing promote-to-pattern step), never automatic.
  • Search reads both (the patterns MCP and the dashboard search each learn a second root) and every hit shows its tier. The private tier must be excluded from docs generation, CI and anything that leaves the machine.
  • Frontmatter records provenance: source URL, license, and whether the pattern derives from employer work; the promotion step checks it.
  • The patterns README rule "do not place files under workspace.data_root" must be narrowed to "not under projects/work/** or lab/**".
  • My reading of "cautious": the public tier gets no employer-derived or vendor-excerpt patterns until Nathan has explicitly cleared them. Whether an employer agreement permits publishing generalized solutions at all is recorded as not yet checked (2026-09-23): a fact only Nathan can supply. Until it is checked and cleared, the public tier holds no employer-derived material; this is a standing constraint, not an open design question.

Task Browser

  • T1. AnyType is the only task store; the dashboard is a view. The dashboard gets a real browser (open and done, filterable by project), a richer create form (title, due_date, optional project, recipient) and a complete or reopen action (set_properties, verified). It is deliberately not a task manager: no priorities, recurrence, subtasks or reminders in v1. Agents use the AnyType MCP directly, with the convention (search by project, set_properties to complete) documented in one place. No external tool is adopted. If AnyType proves too clunky, the fallback is a new MCP later (Nathan). Search results carry no properties, so the browser fetches each visible task (capped to the page) and revisits if it feels slow.

  • T2. "Recipient" is a new recipient property: a relation to Person objects, added to the task type in both the agent and work spaces. It means who the task's deliverable is owed to, distinct from assignee (who does the work) and participants (people involved). It is a live-PKM schema change, so it goes through the same explicit approval step as P6, batched with it because the relation needs the person type to exist in the Work space first. Deadline uses the built-in due_date and needs no schema work.

  • Not yet verified: whether the scoped API key may create properties or edit types. To be checked on a throwaway property when this is implemented. If the key cannot, Nathan will create the property manually (fallback agreed).
  • This makes "what do I owe this person?" a real query through the AnyType MCP.

  • T3. Agent writes to AnyType are gated by a PreToolUse hook, coupled with the per-project matrix (P3). Today agent_write is enforced only by the dashboard's own write code and by prose in a skill; for an agent using the AnyType MCP nothing enforces it, and the scoped key gates reads only. The hook matches the AnyType write tools, looks up the target space in spaces.json, and applies the policy: never blocks, approval prompts, full allows. It fails closed: a write whose space cannot be determined prompts. The script lives in this repo and is installed at user level (like mcp-install-user), with a drift check. Tests calibrate first: a Work-space write must be blocked, an agent-space write must pass.

  • No exemption for completing a task (my reading of Nathan's answer): every write to an approval space prompts, and the friction is handled by the session-bounded grant below.
  • Cursor does not run Claude Code hooks. A Cursor session with the AnyType MCP enabled is unguarded, so the Cursor anytype connection must be read-only or off by default in the per-project matrix. This gap cannot be closed with a hook.
  • "Don't ask again" with a session-bounded TTL (Nathan). A hook cannot attach to Claude's native dialog or see the user's click, and the native "don't ask again" writes a persistent permission rule that has no TTL. So the TTL grant needs its own mechanism (see T4).
  • Verify against the primary docs before an issue: that PreToolUse hooks can return an ask decision, and that the hook input carries the session id. Both are from memory.

  • T4. The session-bounded grant is an explicit command, scoped to one space in one session. Because a hook cannot see the user's click or extend Claude's native dialog (whose "don't ask again" writes a permanent rule), the grant is a separate action the hook consults: for example make anytype-grant SPACE=work TTL=30m, whose exact text the hook's prompt shows. It writes a grant record keyed by session id and space.

  • Defaults: 30 minutes, capped at 2 hours, and it ends with the session; a resumed session cannot reuse an old grant. If the hook input does not carry a session id, fall back to a short absolute TTL and bring that back to Nathan.
  • Never covered by a grant: deleting objects and changing types or properties always prompt, so the schema changes approved in P6 and T2 stay deliberate.
  • The grant file is state, not config. It lives under ~/tool-chest-data/, is never committed, and expired records are pruned on read. Each allow-by-grant is written to an audit file so the writes can be reviewed afterwards. An agent cannot grant itself, which needs an explicit mechanism: the grant is a command, but an agent can run commands through its own shell tool, so "the user runs it" is not enough. The gate must also refuse shell tool calls that invoke the grant command (added to the grants issue, #36, after this gap was found while writing it).
  • A grant can be revoked early with a matching command (2026-09-23).

Search reachability

  • S1. Every search hit is reachable through one read-only in-dashboard viewer for local files. A hit's identity is (corpus, relative path within that corpus), never an absolute path: the server looks up the corpus root itself, so the endpoint has no path parameter a caller could aim at ~/.ssh; the relative path is validated to stay under the root (no .., symlinks resolved, allowed extensions only). The excerpt becomes a window around the first match with the query terms highlighted, replacing the first 240 characters of the file. Both patterns tiers (A5) go through the same endpoint with the tier shown. AnyType objects (tasks, projects, people) are a separate follow-up: search does not query AnyType today and an anytype:// deep link is unverified.
  • Amendment (Nathan): files added through the Capture Inbox are also search results. Where a file can be rendered in the browser easily and safely (markdown, HTML, PDF), the dashboard renders it. For every file-type result, the default behaviour is to show the file's physical location in the file system for manual access.
  • Consequences still to settle: see the questions on rendering safety and on whether attachment content is indexed.

  • S2. Inline rendering is a short allow-list: plain text, PDF, raster images, and markdown (sanitized). Everything else, including HTML, is location only. Nathan: this is really just for text; everything else is handled manually in the file system. HTML is never rendered in the dashboard's origin. SVG is excluded from images because it can carry script. The reason for the strictness is that the dashboard's own guards (Host check on every request; Origin plus a per-launch token on POST) do not protect against script running inside the dashboard's origin, which could use the page's token to trigger capture, checks or config regeneration.

  • Every hit shows its physical path with a copy control, for every corpus. PDFs and images are served inline with X-Content-Type-Options: nosniff and a CSP sandbox response header.
  • Markdown sanitizing must be verified, not assumed. The dashboard uses only the standard library, so a sanitizer is a design choice: an escape-first renderer with no raw HTML pass-through and links limited to http, https and relative. A test must feed hostile markdown (script tags, javascript: URLs, onerror attributes, HTML entities) and assert nothing executable survives, calibrated first against an unsanitized render so it can fail.
  • Attachments are searchable by metadata only in v1 (filename, notes, project, date), confirmed 2026-09-23. Their contents are not indexed, which keeps rag_deny_globs (projects/work/**) intact.
  • No "Reveal in Finder" button in v1 (2026-09-23). Show the path with a copy control; the button (a token-gated POST that opens Finder) can be added later.

  • S3. The library and research chips are wired in v1, by calling their existing CLIs. Nathan: these are among the most useful tools and belong in v1. The dashboard runs uv run --directory library library search ... and the research equivalent, and parses the JSON. The dashboard stays standard-library only and each project keeps its own environment.

  • PyCalibre is not adopted. Verified: library/ calls Calibre's own calibredb and ebook-convert binaries (library/src/library/calibre.py), with no PyCalibre dependency, so the earlier recollection was wrong. PyCalibre's usage page states neither whether it reads metadata.db directly nor any version or requirements, and it supports writing to a library, the wrong direction for a read-only integration.
  • Identity: library = (library, Calibre book id, chunk id); research = (research, slug, chunk id).
  • Reachable means: the passage shown with surrounding context; the physical location (the Calibre book path, or research's local archive_path); and for research the original URL (url, pdf_url) as an external link. Rendering follows S2: research's archived markdown renders sanitized, a PDF book renders inline, an EPUB is location only.
  • Two small additions: a passage command in each CLI (both MCP servers have get_passage, the CLIs do not), and an explicit "Ollama unreachable" message, because these searches embed the query through Ollama and would otherwise return silently empty when it is down.
  • Cost: slower than the wiki search (a process start per search plus an Ollama call). Both chips are unchecked by default (2026-09-23), so the user opts in per search.
  • Roadmap (deferred): "Open in Calibre". Calibre may offer a calibre:// URL scheme; this is from memory and unverified.

Health display and OmniRoute

  • H1. Health dots use three states from one source, and the OmniRoute page links out. (Accepted by Nathan as one decision.)
  • Three states everywhere: green ok, red fail, grey unknown or not run. The header and the ring share one mapping.
  • One source of truth: the header reads /api/health, as the ring does, so they cannot disagree.
  • Refresh every 60 seconds while the page is visible, and immediately after Run checks and any action that changes state.
  • OmniRoute: a link to a configured dashboard_url (defaults to base_url, so the API and UI addresses can differ), opened in a new tab with noopener, http or https only. When down, a disabled link with the reason, not a hidden one. It re-probes on page view rather than using the boot snapshot. The state is labelled "responding", not "up", since the probe proves only that the port answers. OmniRoute's own login wall is not tool-chest's concern.

Retiring the old Work pages

  • R1. The Workspaces, Labs and Sandbox pages are retired; their backends are kept and reviewed separately. Remove the three nav leaves and their dashboard routes and forms; the Work group becomes Projects, with Tasks alongside. workspace_fs.py, sandbox_lab.py, the make workspace, open-lab, close-lab and sandbox targets and their tests are left exactly as they are. No deletion in this plan.
  • Why the backends stay: the lab and sandbox code implements a security policy in governance/ (read-only host analysis, a --network none container plan, and a lab scope named employer-ephemeral that matters for employer material). It should not be removed as a side effect of a UX change, and the policy has not been read as part of this plan.
  • Evidence: ~/tool-chest-data holds only lab/ (one entry) and models/. No workspace folder has ever been created, so nothing needs migrating, and the new per-project layout (A1) does not reuse the old data_root/<domain>/<slug> paths.
  • Follow-up: a separate keep-or-drop review of these backends, in the style of the local-agent review. The Sandbox may deserve to stay as a CLI.

  • T5. "Keep track of remaining time" is display plus on-request; reminders are out of scope. (A) The browser shows each open task's due date, "due in N days" or "overdue by N days", and a marker for overdue and due-today, sorted by due_date earliest first with undated tasks last. It is computed in the browser from the stored date (no stored state to go stale), in the local timezone; a date with no time counts as due at the end of that day. (B) An agent computes and discusses time remaining on request from the same due_date, using the T1 convention; no new code. (C) Proactive reminders and notifications are out of scope; a "due this week" view or an agent check at session start are the cheaper next steps if deadlines are missed.

Open items

Decided items above are settled. These remain.

Nothing in the design is waiting on Nathan. One standing constraint: the applicable employer agreement on publishing generalized solutions is not yet checked (A5), so the public patterns tier stays free of employer-derived material until it is.

Verified on 2026-09-23 (previously "to verify"):

  • Hooks (T3, T4). From Claude Code's hooks documentation: a PreToolUse hook receives session_id, tool_name and tool_input on stdin, and may return permissionDecision of allow, deny or ask; ask shows the normal permission prompt. MCP tools match as mcp__<server>__<tool> and the matcher accepts regular expressions. Exit code 2 blocks the call; any other failure, including a crash or invalid JSON output, does not block, so a hook fails open. The gate script must therefore catch every error itself and emit a deny or ask. One of two documentation reads said ask was not stated, while a targeted re-read quoted it, so the first acceptance criterion of the gate issue is an empirical check that ask really prompts.
  • AnyType write tools (T3). create-object, patch-object, delete-object, create-property, create-type and update-type all take space_id in their input, so the hook can classify by space. Tools with no space_id (create-space, listing and identity calls) need explicit rules. Every write tool also has dry_run. delete-object only deletes objects the same key created, a server-side limit that complements the gate.
  • The API key. Agent-Key-Scoped-RW, scoped and restricted to read-write on Work, the agent wiki space and Job Hunt. The Private space is not reachable. The gate must therefore cover Job Hunt (approval) as well as Work.
  • Schema changes (T2, P6). Dry runs accepted creating a recipient property (objects) in the Work and agent spaces, creating a person type in Work, and adding recipient to the task type. A dry run validates but does not prove the real write will commit, so the first real write is a checked, approved step. The write schemas do not expose which object types a relation may point to, so recipient cannot be limited to Person objects through the API; it would accept any object unless restricted by hand in the Anytype app. Done 2026-10-06 (#33). recipient (objects) was added to task in both Work and the agent space with add_property, and read back on a throwaway task in each (all throwaways deleted). The Anytype app does not let recipient be limited to Person objects, so it stays permissive and the dashboard enforces person-only (#49).
  • Project link (P5). The task type already has linked_projects (a relation to project objects), due_date, assignee (participants only), status and done. On throwaway objects: the relation is settable and reads back, a project is findable by name, and the query an agent needs is done = false and linked_projects = "<project id>" (an unquoted id fails to parse). Both throwaways were deleted.
  • Person type (P6). The agent space's person type has aliases, affiliations, notes and last-contact properties, the shape to mirror in Work. Done 2026-10-06 (#32). Work already had a person type (created 2026-09-24, with notes, company, title, phone and email), so it was extended, not created: aliases, affiliations and last_contact were added with an add_property ops envelope. Work's type keeps its extra properties and its basic layout (the agent space uses profile). A throwaway Person set all three and read back, then was deleted.
  • Layout moves (P2). layout-apply rewrites paths textually across ~/.claude.json and other global configs, so a per-project local-scope entry, keyed by path, moves with the project. Established by reading the code and its temporary-home tests, not by a real move.
  • Noted, not yet understood: project entries in ~/.claude.json carry enabledMcpjsonServers and disabledMcpjsonServers. They may allow per-project disabling of project-scope servers (a deferred item under P3).

Still to verify:

  • The anytype:// deep-link scheme (S1 follow-up) and Calibre's calibre:// scheme (roadmap).

To settle while writing the issues:

  • The reference format for an attachment held in AnyType (A1): a path relative to data_root plus the hash.
  • The markdown sanitizer design and its hostile-input test (S2).
  • The navigation restructure after the Work pages retire (R1).

Deferred, on the roadmap (no owner yet):

  • URL attachments (A2), AnyType objects as search results (S1), "Open in Calibre" (S3), text extraction from attachments (A4), a keep-or-drop review of the lab and sandbox backends (R1), per-project disabling of user-scope MCP servers (P3), reminders (T5), a semantic or richer prior-art search (A4).

Proposed slices (a proposal, not a decision)

Ordered by dependency; each is meant to be independently shippable. All twelve slices are filed as issues (#23 to #57).

  1. Health dots and OmniRoute link (H1). Independent and small. Filed as #23 (dots) and #26 (OmniRoute, after #23).
  2. Search viewer for local files (S1, S2). (corpus, relative path) identity, match-window excerpts, sanitized markdown, path shown for every hit. Filed as #24 (excerpts), #25 (wiki viewer), #27 (markdown, after #25) and #28 (patterns and code-library, after #25).
  3. Wire library and research search (S3). Adds the passage commands. Filed as #29 (Calibre library, after #25) and #30 (research, after #25 and #29).
  4. AnyType write gate (T3, T4). A prerequisite before any agent writes tasks. Filed as #31 (the hook) and #36 (session-bounded grants, after #31).
  5. PKM schema: person type in Work, recipient on task (P6, T2). A manual approval step, not a code slice. Filed as #32 (person type) and #33 (recipient), both for the maintainer.
  6. Projects: List (P1). Read-only, filesystem-derived. Filed as #34.
  7. Projects: Create and Update, with the generator, drift check and privacy tests (P1a, P2, P3, P4, P6). Filed as #35 (state file and privacy tests), #37 (Claude Code adapter), #38 (Cursor file), #39 (create a project), #40 (AnyType project object), #41 (connection state) and #42 (toggle).
  8. Task Browser (T1, T5). After 4, 5 and 6, because it depends on the gate, the schema and the project link (P5). Filed as #43 (browser), #44 (due date on create), #46 (complete and reopen), #47 (project filter), #49 (recipient and project fields) and #50 (agent convention, with a real-session trial).
  9. Attachments: upload (A1, A2 slice 1), then path (A3). After 7. Filed as #48 (upload, local copy), #51 (AnyType reference), #52 (path input) and #53 (searchable and reachable).
  10. Two-tier patterns (A5). The patterns MCP and dashboard search learn a second root. Filed as #45 (private root), #54 (dashboard search) and #55 (promotion with a provenance check).
  11. Prior-art view (A4). After 9 and 10. Filed as #56.
  12. Retire the old Work pages (R1). After 6, so the nav is never empty. Filed as #57.