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" aredata_root/lab/*(dashboard_state.py). They are data folders, not the repos underworkspace/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/workspaceroot 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.tomlplusmake layout-check/plan/applyalready 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
mvorphans~/.claude/projects/<path-key>memory,~/.claude.jsonentries, worktrees and MCP paths, andlayout-applywants 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 onlyprojects/, 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
maketarget 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.tomldescribes layout; enablement is a different concern. Extend the manifest, add a[project.<name>]section totool-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.jsonwould be generated from tool-chest and committed to that repo. Is that acceptable for every project? Forcontrib/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.jsonservers 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 acontentstring andsubmit_capture()creates one inbox object. Multipart parsing exists (parse_multipart, used by voice upload), so the transport is partly there. - The
workspace isagent_read: true, agent_write: "approval", so a confirm gate already applies to work captures. rag_deny_globs = ["lab/**", "projects/work/**"](relative todata_root) keeps RAG/MCP indexes away from confidential data.patternsis 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_globsfor 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.
patternssearches 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 awork/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
embeddingstable) with alaCLI. About 1.7k lines of Python and 50 passing tests. The code is competent. - Commands:
person,link,note(with owe/owed commitments), and querieswho 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.pyreadinglocal_agent.ollamafor 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.
embeddingsis never populated.who_depthintool-chest.tomlis 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 ofexcerpt(dashboard.js,search-formhandler). No link, no detail view. - The excerpt is not the matching passage.
_lexical_walksetsexcerpt = 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/searchand lists, but nothing that reads one hit back. pathis inconsistent across corpora: absolute for wiki and patterns, a repo-relativerelpathfor code-library (meaningless without the repo).- Two of the five corpus chips,
libraryandresearch, 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-wikiandknowledge/patternsplus 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_globsstill applies), with traversal and symlink handling.static_bytesalready 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()createstype: taskwithdone: falseanddata_classification;list_open_tasks()searches fortype: 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
agentspace returned its 1 task and theworkspace 0, out of 23 and 1 objects respectively. So "can't tell if it exists" is a UI gap, not a broken call. Note theworkspace holds a single collection object: the space the SOW use case targets is nearly empty. - The browser shows names only. Search results carry
id,nameandtypeand 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/tasksdefaults toagent. 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
tasktype 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
agentspace and deleted afterwards, 2026-09-23): done = falseis 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
doneanddata_classificationcorrectly (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 atGET /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 as2026-10-01T00:00:00Z). - Recipient has no property. The space has
assignee(objects),linked_projects,related_projectsandstatus(select), but nothing named recipient.assigneeis 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'stypeshape 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 isassigneeclose 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
workspace isagent_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.
OmniRoute: link out to its own dashboard¶
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 atbase_urlitself. 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 configureddashboard_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.tomlstays 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(underworkspace.data_root), not intool-chest.toml.tool-chest.tomlis 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 intool-chest.tomlbecause they are not sensitive. CI has noprojects.toml, somake projects-checkruns 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
localscope (per project path in~/.claude.json, viaclaude mcp add --scope local; the three scopes local/user/project were confirmed fromclaude mcp add --help). Cursor writes.cursor/mcp.jsononly inprojects/repos, excluded through.git/info/excludeso it never appears as a change.contrib/gets Claude local scope only, if at all.oss/andwork/are never touched. Theclaude-cliadapter ingen_mcp_config.pyis 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,patternsandcode_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,researchandpatternsare 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.anytypeandcode_libraryare the toggleable ones. "PKM" in the original example means theanytypeMCP. -
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, optionalgit 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 runsmkdirand an optionalgit 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 theagentandworkspaces;pkm_projectexists only inagentand 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 aprojectobject named after its working directory, using only the AnyType MCP; it cannot be assumed to readprojects.toml.projects.tomlcaches 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_projectsorrelated_projects). - Verify before an issue is written: that either relation is settable on
the
tasktype, against the live API on a throwaway object. -
[project.<name>]also needs adomainfield (see attachments, below). -
P6. Add a
persontype to the Work space, as part of this plan. Theworkspace has nopersontype today (theagentspace 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; theworkspace isagent_write: "approval". - Required tests (Nathan): data remains gitignored, uncommitted and
private. Proposed shape, to refine before it becomes an issue:
projects.tomland the data root resolve outside the repo; fail ifworkspace.data_rootis configured inside it.- 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. - Nothing the dashboard captures (notes, attachments, person data) is
written to a path inside the repo; capture code may write only to
data_rootor to AnyType. .gitignorecovers 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_globsfor 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_rootplus 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/browseallow-list (home, data root, workspace, minus a deny-list). Defaults:~/Downloads,~/Desktop,~/Documents, configurable intool-chest.tomlunder[capture](the list is not sensitive). Always denied even inside an allowed root: any dot-directory component and the existingDENIED_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 fromworkspace/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 fromwork/. -
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
patternsandagent-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) andagent-wikiholds 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
libraryandresearchchips 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 existingpromote-to-patternstep), 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 underprojects/work/**orlab/**". - 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_propertiesto 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
recipientproperty: a relation to Person objects, added to thetasktype in both theagentandworkspaces. It means who the task's deliverable is owed to, distinct fromassignee(who does the work) andparticipants(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 thepersontype to exist in the Work space first. Deadline uses the built-indue_dateand 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
PreToolUsehook, coupled with the per-project matrix (P3). Todayagent_writeis 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 inspaces.json, and applies the policy:neverblocks,approvalprompts,fullallows. It fails closed: a write whose space cannot be determined prompts. The script lives in this repo and is installed at user level (likemcp-install-user), with a drift check. Tests calibrate first: a Work-space write must be blocked, anagent-space write must pass. - No exemption for completing a task (my reading of Nathan's answer): every
write to an
approvalspace 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
anytypeconnection 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
PreToolUsehooks can return anaskdecision, 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 ananytype://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: nosniffand a CSPsandboxresponse 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,onerrorattributes, 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
libraryandresearchchips are wired in v1, by calling their existing CLIs. Nathan: these are among the most useful tools and belong in v1. The dashboard runsuv 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 owncalibredbandebook-convertbinaries (library/src/library/calibre.py), with no PyCalibre dependency, so the earlier recollection was wrong. PyCalibre's usage page states neither whether it readsmetadata.dbdirectly 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
passagecommand in each CLI (both MCP servers haveget_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 tobase_url, so the API and UI addresses can differ), opened in a new tab withnoopener, 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, themake workspace,open-lab,close-labandsandboxtargets 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 nonecontainer plan, and a lab scope namedemployer-ephemeralthat 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-dataholds onlylab/(one entry) andmodels/. No workspace folder has ever been created, so nothing needs migrating, and the new per-project layout (A1) does not reuse the olddata_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_dateearliest 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 samedue_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
PreToolUsehook receivessession_id,tool_nameandtool_inputon stdin, and may returnpermissionDecisionofallow,denyorask;askshows the normal permission prompt. MCP tools match asmcp__<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 saidaskwas not stated, while a targeted re-read quoted it, so the first acceptance criterion of the gate issue is an empirical check thataskreally prompts. - AnyType write tools (T3).
create-object,patch-object,delete-object,create-property,create-typeandupdate-typeall takespace_idin their input, so the hook can classify by space. Tools with nospace_id(create-space, listing and identity calls) need explicit rules. Every write tool also hasdry_run.delete-objectonly 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
recipientproperty (objects) in the Work and agent spaces, creating apersontype in Work, and addingrecipientto thetasktype. 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, sorecipientcannot 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 totaskin both Work and the agent space withadd_property, and read back on a throwaway task in each (all throwaways deleted). The Anytype app does not letrecipientbe limited to Person objects, so it stays permissive and the dashboard enforces person-only (#49). - Project link (P5). The
tasktype already haslinked_projects(a relation toprojectobjects),due_date,assignee(participants only),statusanddone. On throwaway objects: the relation is settable and reads back, a project is findable by name, and the query an agent needs isdone = false and linked_projects = "<project id>"(an unquoted id fails to parse). Both throwaways were deleted. - Person type (P6). The agent space's
persontype has aliases, affiliations, notes and last-contact properties, the shape to mirror in Work. Done 2026-10-06 (#32). Work already had apersontype (created 2026-09-24, with notes, company, title, phone and email), so it was extended, not created:aliases,affiliationsandlast_contactwere added with anadd_propertyops envelope. Work's type keeps its extra properties and itsbasiclayout (the agent space usesprofile). A throwaway Person set all three and read back, then was deleted. - Layout moves (P2).
layout-applyrewrites paths textually across~/.claude.jsonand 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.jsoncarryenabledMcpjsonServersanddisabledMcpjsonServers. 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'scalibre://scheme (roadmap).
To settle while writing the issues:
- The reference format for an attachment held in AnyType (A1): a path relative to
data_rootplus 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).
- Health dots and OmniRoute link (H1). Independent and small. Filed as #23 (dots) and #26 (OmniRoute, after #23).
- 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). - Wire
libraryandresearchsearch (S3). Adds thepassagecommands. Filed as #29 (Calibre library, after #25) and #30 (research, after #25 and #29). - AnyType write gate (T3, T4). A prerequisite before any agent writes tasks. Filed as #31 (the hook) and #36 (session-bounded grants, after #31).
- PKM schema:
persontype in Work,recipientontask(P6, T2). A manual approval step, not a code slice. Filed as #32 (persontype) and #33 (recipient), both for the maintainer. - Projects: List (P1). Read-only, filesystem-derived. Filed as #34.
- 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).
- 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).
- 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).
- 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).
- Prior-art view (A4). After 9 and 10. Filed as #56.
- Retire the old Work pages (R1). After 6, so the nav is never empty. Filed as #57.