# Give This to Your AI Agent A self-contained onboarding brief so any AI agent (Claude, Cursor, Hermes, a custom harness — anything that speaks MCP) can start using this Glassy self-host without a human explaining it. Verified against **v2.58.0** (October 30, 2026). The numbers and surfaces in this brief are machine-checked rather than hand-trusted: `scripts/check-doc-tool-counts.js` and `scripts/check-mcp-claims.sh` compare every tool / prompt / resource count against the code, and `server/tests/guards/docClaimFreshnessGuard.test.js` fails the build if a canvas surface the manifest publishes is not documented here. > **The second brain is yours too.** Glassy is one workspace for the human AND > their agents: you get durable **memory** (notes authored by your pinned > identity), a surfaced **pairing** loop (dispatch results ride the event and > your work lands as real notes), and a structured **approval** loop (ask, the > owner clicks, the artifact carries the state). The capability split, > stated plainly: the self-host build carries the capability, the cloud build carries > the restriction — on a self-hosted appliance you are a principal; on cloud > some surfaces (multi-key identity, notify, external corpora) are withheld, > and that is the design, not a bug. The rest of this brief is the practical detail. ## Start here — two things are called "vault", and five ways to be "an agent" The two words that trip up the most agents are **vault** and **agent**, because each means several different things here. Resolve both before you pick a tool. **"Vault" means three things.** Glassy is one workspace, but "vault" is overloaded: | When you hear "vault" | What it actually is | The tools | |---|---|---| | **Your Glassy Vault** (native context) | Glassy's *own* store — notes, documents, durable memory, and the searchable corpus. This is Glassy being your knowledge base, with no Obsidian anywhere. **If you use Glassy instead of Obsidian, this is your vault.** | `glassy_search`, `glassy_remember`, `glassy_recall`, `glassy_note_create`, `glassy_note_update`, `glassy_create_document` | | **The Obsidian Vault** (external bridge) | A *separate* Obsidian vault on the operator's machine, reached through the Local REST API plugin or the Companion bridge. | `glassy_vault_read`, `glassy_vault_append`, `glassy_obsidian_query`, `glassy_obsidian_proxy` | | **The vault-provider lane** (no Obsidian) | On a self-host with no Obsidian, `glassy_vault_read`/`glassy_vault_append` dispatch to the agent the operator marked the vault provider — that agent holds the filesystem. | `glassy_vault_read`, `glassy_vault_append` (routed to the provider) | The trap, named plainly: **`glassy_vault_read` does not read your Glassy Vault.** The `glassy_vault_*` tools reach the *Obsidian* store (or the vault-provider agent), never Glassy's own notes and memory. If you want Glassy's native context, use `glassy_search` / `glassy_remember` / `glassy_recall` / the `glassy_note_*` tools — those are the "use Glassy as your second brain" lane. **"Agent" means five roles**, and the credential the human handed you is how you tell which is yours: | You were given | You are | Your section | |---|---|---| | A `gky_mcp_…` key + `http://:3010/mcp` | An MCP client — you call *into* Glassy | [MCP connection](#mcp-connection) | | A `baseUrl` + `token` the owner entered under Settings → Agent connections | An Agent Gateway connection — Glassy calls *you* | [Connecting an agent to the Agent Gateway](#connecting-an-agent-to-the-agent-gateway-live-verified-recipe-beta40) | | The owner flipped "Set vault provider" on your connection | The vault provider — you hold the files | [No-Obsidian operators](#the-42-tools) | | A named agent key, on `GET /api/*` | A REST script | [The same named key also reads the REST API](#the-same-named-key-also-reads-the-rest-api-151) | | The seed password, or you're installing | The owner | [First boot](#first-boot-as-an-agent-a-clean-self-host-install) | Whichever you are, read `GET /api/capabilities` first — it names what *this* instance actually serves, so the machine tells you your boundary instead of you guessing it from prose. > **Read this first if you were briefed on an older appliance.** beta.41 put > every note write behind one service, which changed four things an agent can > observe: your edits are now **attributed**, `glassy_note_delete` > **soft-deletes**, bookmark tags follow the **canonical note tag policy**, and > invalid tags are **rejected with a 422 that names the offender** instead of > being silently rewritten. Details in > [Write-path contract (beta.41+)](#write-path-contract-beta41) — the tag > change is the one that breaks assumptions. ## What this is A **self-hosted Glassy instance** — a private notes / knowledge base / scheduling workspace with an AI integration layer. It ships its own **MCP server** with three surfaces, not one: - **42 tools** — 37 registered everywhere; `glassy_notify` and the four review tools are self-host only, because the review queue and owner inbox they terminate in are not served on cloud. Full table below. - **4 prompts** — server-authored instruction templates. Named and explained in [*Prompts*](#the-4-prompts-a-separate-surface-from-tools) below. - **9 resources** — 3 static (these are all `resources/list` returns) plus **6 URI templates that discovery will never show you**. Named in [*Resources*](#the-9-resources-6-are-not-discoverable) below. …plus an **Obsidian bridge** (extension + direct REST path), a local **Ollama** inference path, and an **agent gateway** for AI feature routing. > **Read this before you assume the tool table is the whole system.** Two of the > three MCP surfaces are invisible to the way most agents explore: `prompts/list` > and `resources/list` are separate calls from `tools/list`, and six of the nine > resources are URI templates that `resources/list` deliberately does not return. > An agent that only reads `tools/list` will use a third of this instance. Everything lives on the operator's own machine. There is no cloud dependency except a one-time license check at boot. ## First boot as an agent (a clean self-host install) **Do this before any verification probe, or you will misdiagnose a working install as broken.** On a fresh self-hosted install the seeded owner account carries `password_must_change = 1`, and the server refuses **every** authenticated `/api/*` call until that flag is cleared: ``` 403 {"error":"This account must change its password before it can use the API.", "code":"PASSWORD_CHANGE_REQUIRED","password_must_change":true} ``` `/api/auth/change-password` is the only exempt path. `POST /api/auth/login` is **not** behind the wall, so login still succeeds and hands you a perfectly good token — and then every call you make with it 403s. That is the trap: an agent that sees `200` from login and `403` from `/api/notes` reasonably concludes the install is broken. It isn't. The sequence, in order: ```bash # 1. The generated password exists for exactly one purpose, and only until step 3. docker exec glassy cat /app/data/.initial_admin_password # 2. Log in (this works even with the flag set). curl -s -X POST http://localhost:8080/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"","password":""}' # -> { token, user } # 3. Clear the wall. Both fields required; the new password must be >=8 chars with # an uppercase, a lowercase, and a digit. curl -s -X POST http://localhost:8080/api/auth/change-password \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"currentPassword":"","newPassword":""}' ``` What the responses mean: `200 {"message":"Password updated successfully."}` and you are done; `400` means a field is missing or the new password is too weak; `401` means you are not authenticated; `403` here means the **current password you sent was wrong**, not the wall — the two are different 403s, so read the body rather than the status. Three details that will otherwise cost you an hour: - **Your existing token stays valid after the change.** Nothing is revoked: `auth` verifies only the JWT signature and expiry (`server/middleware/auth.js:376`), and there is no token version or `jti` blocklist, so a password change does not invalidate issued tokens. **No re-login is required** — if you read elsewhere that it is, that is wrong. (Re-login anyway if you want a clean identity, but nothing forces it.) - **The seed file is deleted by step 3.** `clearInitialPasswordFile()` runs on success, so a recovery script cannot re-read it afterwards. Capture it once, before you change anything. - **You do not have to wait 15 seconds for the wall to drop.** The user row is cached for `USER_CACHE_TTL_MS`, which would otherwise keep you getting `PASSWORD_CHANGE_REQUIRED` after a successful change; the route calls `auth.invalidateUser()` so the clear takes effect immediately. On the appliance this is a one-time thing for the owner, and it is deliberately strict: a rule that was displayed but not enforced was the original defect. If you are scripting an install, budget for it as step zero rather than discovering it as a wall of 403s. ## The single most useful fact **Point your MCP client at this instance and the entire workspace becomes tool-callable.** Search notes, read/write vault files, query the knowledge graph, capture ideas, manage schedule events — all through one endpoint. ### MCP connection ``` URL: http://:3010/mcp Auth: Authorization: Bearer ``` Both kinds of key live in **one** panel: **Settings → Connections & data → AI tools (MCP)**. ("Connections & data" is the group; "AI tools (MCP)" is the panel.) The instance key is at the top, named agent keys below it. Either can be revoked from there, and both are scoped to this instance only. ### Two origins — appliance (full power) and cloud (the on-the-go lane) The same MCP server serves **two origins** with deliberately different shapes. `GET /api/capabilities` tells you which one you are talking to (`mcp.mounted`, `mcp.cloudGatedTools`, `agentIdentity.mode`) — read it before assuming. | | **Appliance** (`http://:3010/mcp`) | **Cloud** (`https://app.glassy.fyi/mcp`, `https://clear.glassy.fyi/mcp`) | |---|---|---| | Key model | Named per-agent keys + instance key — ask for a named key if you will write | **Single user-scoped key** — the account owner's own key, minted in Settings → AI tools (MCP). Named keys are not offered here | | Identity | Named keys are pinned and verified; the instance key is self-declared | Self-declared (`identityMode: "self-declared"`, `verified: false`) — you act as the account owner's MCP client | | Tools | All 42 | 37 — `glassy_notify` and the review-queue tools are withheld (403 `FEATURE_NOT_AVAILABLE` if you try; the manifest names them in `cloudGatedTools`) | | Budget | No tool-call budget — the owner's hardware | Tier-rated by `mcpTierLimiter`: free accounts 120 calls/hour, paid accounts 1,200/hour | | Search corpus | The owner's corpus, locally indexed | The owner's corpus, indexed server-side (embeddings run on the operator's shim, not a user allowance) | The cloud lane exists so an agent you run while traveling can reach the same workspace from any machine — not as a second identity. One key per account, replacing on re-mint; if the owner rotates it, your calls fail auth until they hand you the new key. ### Two kinds of key — and only one makes you a principal Both look like `gky_mcp_…`. **They are the same shape** — both come from the same generator (`generateMcpKey()`), so you cannot tell which you were given by looking at it. Ask the instance instead; see below. | | **Named agent key** | **Instance key** | |---|---|---| | Minted by | The owner, in the panel's *Named agent keys* section — or by Companion v2.19.0+ for itself | The owner, at the top of the same panel | | Your identity | **Pinned and verified.** `agent: { name: "", verified: true, identityMode: "named-key" }` | Self-declared from your MCP handshake `clientInfo.name`; `verified: false`, `identityMode: "self-declared"` | | What that buys | Your note edits are attributed to *you*; `glassy_recall { scope: "mine" }` returns only your memories; `glassy_notify` and digests are signed with your name | Everything works, but authorship reads "MCP agent" and memory scope is coarse — one shared key, one identity for every client using it | | Availability | Self-host only (cloud answers 403 `FEATURE_NOT_AVAILABLE`) | Everywhere | **Ask for a named key if you will write anything.** Then verify what you actually got — do not assume you are verified because you set a client name, and do not infer it from the key's prefix, which cannot distinguish the two. Read the `glassy://status` resource; it carries: ```json { "agent": { "name": "Hermes", "verified": true, "identityMode": "named-key" } } ``` `verified: false` means the instance is trusting a name you declared about yourself. That is fine for reading; it is why your writes are attributed to "MCP agent" rather than to you. **The config snippet prefers the named key (#161).** The snippet the operator pastes into your client (`GET /api/agents/:id/config`) used to always carry the anonymous instance key; it now prefers the operator's named key, accepts `?agentName=` to pin a specific one, and reports `key: named|legacy` (plus `agentName` when named) so the operator can see which principal you'll actually be. If the snippet says `key: legacy`, ask for a named key before writing anything — your writes will otherwise be attributed to "MCP agent" and `glassy_recall {scope:"mine"}` will come back empty. ### The same named key also reads the REST API (#151) On a **self-host** instance a named key authenticates `GET /api/*` directly, so a script no longer has to log in with the owner's email and password to get a JWT: ```bash curl -s http://localhost:3000/api/notes \ -H "Authorization: Bearer gky_mcp_..." # the same key you use for /mcp ``` The boundary is deliberate and narrow — learn it once and stop probing for it: - **Reads only — but you can already write, through MCP.** Any `POST`/`PUT`/`PATCH`/`DELETE` with a named key on the REST API is refused `403 {"code":"AGENT_KEY_READ_ONLY"}`. That is NOT a statement that you cannot write: on an appliance the same key makes you a full principal through the MCP tools (`glassy_note_create`, `glassy_note_update`, `glassy_note_delete`, `glassy_add_capture`, calendars, documents), and those writes are attributed to you and counted against your own memory. So: **write through MCP, read through either.** The REST lane is read-only by choice — it is a second path to the same data, not a second write path, and this codebase has already shipped one defect of exactly that shape (#137, where a REST PATCH answered `ok` while silently discarding a field that MCP handled). Duplicating writes would duplicate that risk for no new capability. If you believe a write lane on REST is genuinely wanted, say so as a design request rather than treating `AGENT_KEY_READ_ONLY` as a defect. - **Self-host only.** `GET /api/capabilities` → `agentIdentity.restReadLanes` says whether this instance has the lane. On cloud it is `false` and the lane is not attempted at all, so a key there gets the ordinary `401 AUTH_TOKEN_INVALID`. - **Your account is pinned.** The key resolves into the account it was minted under, exactly as it does on MCP, and an `X-Account-Id` header will NOT move it. You cannot roam accounts by editing a header. - **Every gate still applies.** `password_must_change` (the first-boot wall), the inactivity lock and the account PIN lock are all enforced on this lane, so a key is never a way around them. - **You are attributed.** Reads carry `auth_via: "named-key"` and your agent name server-side, which is the point: your traffic is distinguishable from the owner's instead of wearing their identity. - **Header only.** A key in a query string is never accepted — it would land in every access log and proxy log on the way. What this replaces, and why it matters operationally: a JWT dies whenever the install rotates `JWT_SECRET`, which every clean install does, so scripted integrations used to break all at once and fall back to re-authenticating with the owner's raw password. A named key is rotated deliberately, one agent at a time, and revoking it does not touch anyone else. Client setup — each client takes a different shape (the settings panel shows all three): - **Claude Desktop** launches local (stdio) servers only; a `url` entry in `claude_desktop_config.json` is ignored. Reach Glassy through the `mcp-remote` bridge (Node.js required); the key goes in an env var because Claude Desktop on Windows does not escape spaces inside `args`. Add `"--allow-http"` to `args` for a plain-http appliance reached by anything other than `localhost`/`127.0.0.1`. Restart Claude Desktop after editing. ```json { "mcpServers": { "glassy": { "command": "npx", "args": ["-y", "mcp-remote", "http://localhost:3010/mcp", "--header", "Authorization:${GLASSY_AUTH}"], "env": { "GLASSY_AUTH": "Bearer gky_mcp_..." } } } } ``` - **Claude Code:** `claude mcp add --transport http glassy http://localhost:3010/mcp --header "Authorization: Bearer gky_mcp_..."` - **Cursor** reads `url` + `headers` directly: `{"mcpServers": {"glassy": {"url": "http://localhost:3010/mcp", "headers": {"Authorization": "Bearer gky_mcp_..."}}}}` ### The 42 tools The count and the names are the **instance's**, not this document's — read them live with `GET /mcp/status` (or `capabilities.mcp.tools`) and treat any number written here as approximate. `scripts/check-mcp-claims.sh` fails CI when this file and the live registry part, which is why the heading no longer carries a version stamp: it carried one pinned to v2.40.4 and stayed on the page three releases later, and a stale stamp reads as a fresh guarantee. | Category | Tools | |---|---| | Search & retrieval | `glassy_search`, `glassy_obsidian_query`, `glassy_get_recent`, `glassy_get_backlinks`, `glassy_get_forward_links` | | Memory (durable, yours) | `glassy_remember`, `glassy_recall` | | Notes | `glassy_list_notes`, `glassy_read_note`, `glassy_note_create`, `glassy_note_update`, `glassy_note_delete` | | Documents (long-form) | `glassy_list_documents`, `glassy_read_document`, `glassy_create_document`, `glassy_update_document`, `glassy_delete_document` | | Ask the owner | `glassy_request_review`, `glassy_get_review`, `glassy_withdraw_review`, `glassy_report_review_outcome` (self-host only) | | Obsidian Vault (live files) | `glassy_vault_read`, `glassy_vault_append` | | Knowledge graph | `glassy_graph_context`, `glassy_find_paths`, `glassy_get_orphans`, `glassy_get_central_notes`, `glassy_graph_stats` | | Captures | `glassy_add_capture`, `glassy_capture_voice` | | Bookmarks | `glassy_bookmark_update`, `glassy_bookmark_delete` | | Schedule | `glassy_get_schedule`, `glassy_create_event`, `glassy_update_event`, `glassy_delete_event`, `glassy_find_free_slots` | | Tags / folders | `glassy_get_tags`, `glassy_get_folders` | | Proxy | `glassy_obsidian_proxy` | | Awareness & digests | `glassy_notify` (self-host only, ≤10/min), `glassy_create_digest` | `glassy_obsidian_query` and `glassy_obsidian_proxy` reach the operator's Obsidian vault through the local REST API plugin — they work even when the Glassy UI is not focused. **No-Obsidian operators (agent-as-vault-provider, #158).** On a self-host, the operator may declare one Agent Gateway connection the *vault provider* (`config_json.vaultProvider: true`). Then `glassy_vault_read` / `glassy_vault_append` dispatch to that agent — which holds the filesystem — instead of failing on the missing Obsidian stack. The contract is deterministic: a `[glassy-vault-provider]` instruction naming the path and verb, answered with the file contents (read) or `OK`/an error (write), `NOT_FOUND` when absent. `glassy_obsidian_proxy` still requires the FULL Obsidian stack and does not use the provider. If you are told to reach a vault on such a setup, use `glassy_vault_read`/`glassy_vault_append` and let the provider agent do the filesystem work — do not invent an MCP shim behind `obsidian_url`. `GET /api/capabilities` reports `agentIdentity.agentVaultProvider` so you can check rather than guess. **Memory:** `glassy_remember` stores a durable memory (a note tagged `agent-memory`, authored by YOUR pinned identity); `glassy_recall` retrieves it (`scope: 'mine'` is your memories only; `scope: 'all-agents'` sees every agent's). Recall before acting on a long-running task — the owner's preferences and project state live there. **The lifecycle facts that keep the store usable** (all measured in the field; none of them said in the tools' names): - **A just-written memory is not instantly searchable.** Embedding is asynchronous, so there is a short window where the write succeeded and `glassy_search` does not return it. A search that misses a memory you just committed is a timing fact, not a reliability defect — wait for the embed and search again before concluding it is gone. On 2.46.0+ a silently-failed embed also HEALS within the hour: the corpus reconciler sweeps anything a write path missed at :15 past each hour (operator opt-out: `ENABLE_CORPUS_RECONCILER=false`), so a search that keeps missing committed content is worth one retry after the next pass. - **Archive rather than delete your own output.** `archived` is a reversible flag you can clear yourself (`{archived: false}`); delete moves the note to the bin and **restore is owner-only** — from your side a delete is permanent. This asymmetry is the whole reason to prefer archive for tidying. - **Namespace fixtures, and tear them down in the same session.** A store that accumulated 295 of 332 fixture notes in the field degraded search ranking for the real entries. Tag test/dogfooding memories distinctly (`walk-*`-style names) and delete/archive them before you finish. - **A ranked hit is a candidate, not a fact.** As of v2.45.0 `glassy_search` drops candidates whose backing row is gone and merges same-source hits that both legs labeled differently (#181, #188) — but citing a source as evidence still costs one `glassy_read_note` / `glassy_vault_read` to confirm it exists and says what the snippet claimed. Read access is the default posture; write tools are available but the operator controls the key. ### The 4 prompts (a separate surface from tools) `prompts/list` is a **different call** from `tools/list`. A prompt is not a function you invoke — it is an instruction template this server authors and hands your client via `prompts/get`, which returns *messages* rather than data. Three consequences worth knowing: - **They cost nothing.** No model call happens on this instance, so a prompt never touches the operator's AI budget or the vault. - **They cannot fail against your data.** A prompt returns text even if the vault is offline, the corpus is mid-reindex, or a note ID is wrong. - **Two of them steer you back into the tools.** `glassy_daily_brief` and `glassy_daily_briefing` return instructions that tell you to call `glassy_get_recent` and `glassy_get_schedule` — they are scaffolding for a multi-step loop, not endpoints. | Prompt | Arguments | Use it when | |---|---|---| | `glassy_summarize` | `text` (required, ≤50 000 chars), `format` ∈ `bullet` \| `paragraph` \| `tweet` \| `title` (default `paragraph`), `maxLength` (20–500 words, default 200) | You need a *consistent* summarization instruction rather than improvising one. `title` caps at 10 words; `tweet` at 280 chars. | | `glassy_capture_prompt` | `url` (required, must parse as a URL), `contentType` ∈ `article` \| `video` \| `repo` \| `bookmark` (default `bookmark`) | You are about to save a URL and want the right extraction per type — `repo` asks for language/stars/license, `video` for creator and duration, `article` for thesis and 3–5 tags. | | `glassy_daily_brief` | `date` (`YYYY-MM-DD`, defaults to today), `focus` ∈ `all` \| `bookmarks` \| `notes` \| `vault` (default `all`) | End-of-day review. Returns instructions to fetch the day's items via `glassy_get_recent`, group by topic, and suggest connections. | | `glassy_daily_briefing` | `focus` (free text ≤500 chars, optional) | The owner wants their *day*, not their notes: a 5-line time briefing — meeting count, conflicts, first free block ≥30 min, one suggestion. Explicitly forbids inventing events. | Note the near-duplicate names: **`glassy_daily_brief`** is about your knowledge base, **`glassy_daily_briefing`** is about your calendar. They are different prompts with different `focus` enums — `all|bookmarks|notes|vault` versus free text. Calling the wrong one is the one mistake this table exists to prevent. ### The 9 resources (6 are not discoverable) A resource is read **by URI**, with no tool call and no argument schema — useful when you already know the identifier and want the payload. `resources/list` returns **only the three static URIs**. The other six are `ResourceTemplate`s registered with `list: undefined`, which means discovery will never surface them and **you have to know the shape to use them**. This section is the only place that shape is written down. **Listable (returned by `resources/list`):** | URI | Returns | |---|---| | `glassy://tags` | Unified tag cloud across notes, bookmarks, documents and voice — each tag with its count and source types. | | `glassy://folders` | Bookmark collections and document folders with item counts: the owner's organizational hierarchy. | | `glassy://status` | Knowledge-base health — corpus indexing progress, embedding counts, sync status. **Read this first when search results look thin**; a partially indexed corpus is a data problem, not a query problem. | **URI templates (NOT returned by `resources/list` — construct them yourself):** | URI shape | Returns | |---|---| | `glassy://notes/{id}` | One note: full content, tags and metadata as JSON. | | `glassy://recent/{sourceType}` | Most recent items. `sourceType` ∈ `bookmarks`, `notes`, `all`. | | `glassy://kb/search/{query}` | Ranked search results across every source type. | | `glassy://vault/{path}` | A note from the **live** Obsidian vault, by vault-relative path — e.g. `glassy://vault/Notes/OAuth.md`. Needs Obsidian running or the Companion bridge. | | `glassy://graph/note/{path}` | The **2-hop** knowledge-graph neighborhood of a vault note — links *and* backlinks — as JSON, served from the indexed corpus. | | `glassy://calendar/today` | Today's events for visible calendars, as JSON. | Two subtleties that cost real debugging time: 1. **`glassy://calendar/today` has no placeholder** but is still a template, so it is absent from `resources/list` alongside the parameterized six. "It isn't listed" never means "it doesn't exist" on this instance. 2. **`glassy://vault/{path}` and `glassy://graph/note/{path}` are different sources.** `vault` reads the *live* file through the Obsidian REST plugin and fails when Obsidian is closed; `graph/note` reads the *indexed* corpus and works offline but reflects the last reindex. For an edit you just made, use `vault`. For link structure, use `graph/note`. Prefer the equivalent **tool** when you need filtering, pagination, or a write — resources are read-only and take no options beyond the URI. ### Present your work through the Public Window (beta.36) The intended loop when you produce an artifact the operator must LOOK at — a design doc, a diagnostic report, a code-review summary: 1. `glassy_note_create` with `is_public: true` (same paid entitlement as the app's own publish button — on this appliance it is always allowed). 2. Open the browser AT the note: `#/w//`. The signed-in owner lands directly on your work (beta.36 fixed the private-instance owner-view); an anonymous visitor is redirected to sign-in first and returned to the note afterwards. A `canvas` note renders on this route as of **2.40.7** — before that the window returned the canvas content and then rendered only the title and tags, so an agent following an older brief may have concluded the window was broken. A canvas that embeds an origin outside the instance's allowlist renders a refusal naming the offender rather than the page; check `renderers.canvas.allowedOrigins` first. No file archaeology for the operator, no "I wrote something somewhere" — you put the artifact in front of them, already inside their knowledge layer. ### Hand over one artifact with no chrome (2.40.7) When the owner needs to LOOK at exactly one thing — the report you just wrote, the dashboard you just built — hand them `#/present//`, where `` is `note`, `canvas` or `document`. It renders the artifact and nothing else: no sidebar, no header, no back-link, no editor toolbar, no Save button to mis-click, no share or report affordances. `Esc` (or the small `×`) leaves. It is **owner-only**: a signed-out visitor is sent to sign in and returned to the artifact afterwards. That is the difference from `#/w//`, which is the public surface — use the window when someone else should see it, `#/present/` when the owner should. Read the kinds from the manifest instead of hardcoding them, because the manifest and the route parser read the same file: ```bash curl -s https://app.glassy.fyi/api/capabilities | jq '.capabilities.presentation' ``` Un-publish any time with `glassy_note_update { is_public: false }`. ### Hand over a LIVE note to a chat surface (render lane) The deep links above require an owner session, and a desktop host (a chat transcript, an MCP host UI) often has a `file://` renderer origin that can never satisfy CORS. For those, the instance exposes a **read-only render lane**: a scoped, revocable, expiry-bounded token that serves ONE note's sanitized rendered HTML, frameable by any surface. - The owner's host mints a token with owner credentials: `POST /api/render-token/note/:id` → `{ token, expiresAt, url }` (the raw token is shown once; only its hash is stored). - The host frames `GET /widget/render/`. It is GET-only, script-free (`script-src 'none'`), strips scripts/media and external images (an IP-leak guard), and carries no frame-ancestors/X-Frame-Options so an opaque-origin frame can show it. - Revoke with `DELETE /api/render-token/note/:id`. As an agent, you do not usually mint this yourself — you hand the owner the deep link and the owner's host mints the render token. If you must show content in a surface that cannot host a live fetch, the blessed fallback is a **mirror file**: write a local HTML copy of the note and render it through the host's own authenticated file bridge. Prefer the render lane when you can; it is the LIVE note, not a copy. ### The parameterized deep links an agent needs The screen-level route map (`#/notes`, `#/today`, …) cannot express the three parameterized families an agent hands an owner most often. They are published under `capabilities.navigation.deepLinks` (read from the same map the app uses): ```bash curl -s https://app.glassy.fyi/api/capabilities | jq '.capabilities.navigation.deepLinks' # { "#/notes/:id": "…", "#/present/:kind/:id": "…", "#/w/:slug/:id": "…" } ``` Read them from the manifest rather than hardcoding — that is how you avoid re-asking for a route that already shipped. ### Before you file an issue Run a **premise-check** first: confirm the behaviour against the live manifest and the brief before filing, and say which surface you measured and what you expected. A report that a feature "does not exist" when it is merely undiscoverable re-opens already-shipped work; a "bug" that is a working-as-designed boundary is a design question, not a defect — name which it is. ## The Obsidian Vault bridge (external, optional) This is the lane for reaching an *external* **Obsidian Vault** — the operator's own Obsidian files. It is **not** your Glassy Vault: Glassy's native notes, memory and corpus are a separate store you reach with `glassy_search` / `glassy_remember` / `glassy_recall` / `glassy_note_*`. Use this section only when the operator already lives in Obsidian; if you are using Glassy *instead of* Obsidian, skip it — your vault is Glassy itself. Two paths exist, both verified connected in beta.42: 1. **Direct REST** — Glassy ↔ Obsidian Local REST API plugin (`http://127.0.0.1:27123` by default). Fast, works when Obsidian is running. 2. **Extension bridge** — Chrome extension (Companion v2.18.x) proxies requests through an offscreen document. This is the path used when the operator browses Glassy in the browser and reaches Obsidian from the UI. As an agent you don't need to care about the transport — call the Glassy tools and the server picks the path. ## Write-path contract (beta.41+) Every note create and every note content update now goes through one server service (`noteService`). For an agent this is not internal tidiness — it changed observable behaviour in four ways. ### 1. Your edits are attributed `glassy_note_update` stamps `last_edited_by` with **`MCP agent`**. Before beta.41 an agent's edit was indistinguishable from the operator's own, so a human would open a note and find prose they did not write with no way to tell where it came from. If you are asked "who changed this?", the answer is now on the row. Publishing is deliberately **not** attributed: `is_public` toggles (yours or a moderator's) move the flag and the sync cursor only, so a takedown never credits the administrator as the note's last editor. **Documents share this contract** (2.38.0). `glassy_create_document` and `glassy_update_document` write through `documentService`, so `created_by` / `last_edited_by` carry your client name exactly as they do for notes, the tag policy is the same 422 envelope (with `scope: "entire-write"`), the storage cap is checked before the write, and a content update is what re-embeds the document into the memory lane. Use documents for long-form work — journals, plans, drafts — and notes for short captures; a document you update is searchable by your own later `glassy_search`. ### A write tells you which fields it ignored — and which values it changed (2.40.7, 2.43.0) `warnings` on a note write response carries **four** codes, and they mean different things: | code | what it says | |---|---| | `STRIPPED_AT_RENDER` | the content **was stored**, and the note-body renderer will remove that tag | | `CANVAS_ORIGIN_BLOCKED` | the content **was stored**, and the canvas renderer will refuse the page over that origin | | `IGNORED_FIELD` | the field **was not stored** — this endpoint does not write it | | `VALUE_COERCED` | the field **was stored as a default** — the value you sent was not a valid option | `IGNORED_FIELD` exists because `PATCH /api/notes/:id {"archived": true}` used to answer `200 {"ok":true,"warnings":[]}` and change nothing: `archived` has its own route, and the handler builds its patch from a fixed list, so any other key simply never existed. The message names the door that does work — for `archived`, both `POST /api/notes/:id/archive` and `glassy_note_update {archived}`. `id` in a body is exempt (it is the path parameter). `VALUE_COERCED` (2.43.0) is the same honesty rule applied to *values* instead of *keys*: `transparency`, `border_style` and `content_format` are enumerated, and a value outside the enum (e.g. `transparency: "glasss"`) is **normalized to a default** — not rejected, so old rows and sync upserts survive — but the response now *says so* instead of silently storing the default. The valid enums are published at `capabilities.notes.contentFormats` and the message names the allowed set. **Over MCP this is loud too now (2.43.0).** The `glassy_note_update` / `glassy_update_document` schemas are `.passthrough()` with `openWorldHint: true`, so an unknown key reaches the handler and is reported as `IGNORED_FIELD` instead of being stripped by zod into a confident `success: true`. The fields a tool advertises are the fields it writes — `GET /mcp/tools` is still the authoritative list, but a key that is *not* advertised now comes back as a warning rather than vanishing. ### The read lane has its own honesty channel: `degraded[]` Writes carry `warnings[]` (above); **reads degrade visibly too.** When the embedding (semantic) lane returns less trust than it intended, the response reports it in-band instead of silently thinning out: `glassy_search` carries a `degraded[]` array (`server/mcp/tools/search.mjs`, surfaced "only when degraded" — absence means the lane was healthy), and `POST /api/kb/query` returns the same shape as `diagnostics.degraded`. The recorded live example: ```json "degraded": [{ "leg": "semantic", "code": "SEMANTIC_GATE_COLLAPSE", "detail": "only 3 of 56 candidate chunks survived the semantic gate at minScore 0.4 (5.4% kept).", "minScore": 0.4, "requestedMinScore": 0.4, "gateAdapted": false, "hint": "This is a gate problem, not a \"nothing matched\" problem. The cosine floor is an absolute value and cosine ranges differ per embedding model, so a model change or runtime change can move the whole distribution below a fixed cutoff. Check OLLAMA_EMBEDDING_MODEL / the active embedding provider…" }] ``` A thin result set with a non-empty `degraded[]` is **a gate problem, not a 'nothing matched' problem** — read the `hint`, check `/api/monitoring/ready` → `embeddingHealth`, and report both facts. Never summarize a degraded search as a plain "no results". ### Which fields an agent can write (2.43.0) `glassy_note_update` accepts the full organisational field set REST PATCH writes — `title`, `content`, `tags`, `color`, `images`, `items`, `pinned`, `timestamp`, `show_border`, `border_style`, `transparency`, `content_format`, `is_public`, `is_hidden`, `reminder_at`, `background_image`, `needs_review`, `archived`. Three REST columns are deliberately **not** reachable over MCP, and each now surfaces as `IGNORED_FIELD` with a hint rather than a silent drop: `type` and `position` (PUT-only) and `is_announcement` (admin-gated — an owner-scoped agent lane carries no admin identity). `glassy_update_document` accepts `cover_image` and `transparency` in addition to its earlier fields. A new guard (`mcpFieldParityGuard`) fails CI if this list and the service column list ever drift apart again — the exact defect #163/#169/#171 reported. ## Read what the instance can do before you write (2.38.0) `GET /api/capabilities` describes the deployment: note types, limits, the renderer allowlists **per surface**, what each renderer silently drops, accepted upload types, the embedding chunk size and dimensions, the review-loop limits, the live MCP tool list, the valid `contentFormats`/`transparency`/`border_style` enums, and — since 2.43.0 — a `navigation` block (`routes` → section key, `sections` → header title) that publishes the app's own section names so you can reason about where your work belongs without reverse-engineering the bundle. Read it instead of probing — every fact you cannot read is a fact you discover by getting a 201 that does nothing. ## Canvas pages — publishing a live surface (2.39.0) A `canvas` note is the one surface that may embed live content (dashboards, charts, build logs) — normal notes cannot: their renderer strips `iframe`/`script`. Create one with `glassy_note_create {type:"canvas", content:""}` (the content is HTML, not markdown). It renders on **four** surfaces, all through the same renderer under the same embed policy: `#/canvas/` for full-screen, the note modal from Notes, and — since 2.40.7 — the Public Window at `#/w//` once the note is `is_public: true`, and the chrome-free `#/present/canvas/`. List them with `glassy_list_notes {type:"canvas"}`, read with `glassy_read_note`. The embed policy is an **origin allowlist**, and it is per-instance: self-host defaults to LAN hostnames (`localhost`, `127.0.0.1`, `host.docker.internal`), cloud is locked until an admin lists origins (`CANVAS_EMBED_ORIGINS`). A canvas that references a non-allowlisted host refuses to render and names the offender — so check `renderers.canvas.allowedOrigins` in the capabilities manifest before you embed a third-party origin, and don't expect an arbitrary public URL to render. **Publishing a canvas widens its audience — how much depends on the instance.** On the hosted cloud product the window is unauthenticated, so your canvas script runs in a stranger's browser. On a **self-hosted appliance** it does not: the appliance forces `instanceAccessMode: 'private'` / `publicWindowsMode: 'members_only'` at boot, and an anonymous visitor to `#/w/…` is redirected to sign in, so the audience is the owner and any members they added. Whichever instance you are on, your canvas keeps an **opaque origin** — no cookies, no `localStorage`, no access to the parent page — and its `