Skip to Content

Companion Memory

Companion Memory is reachable three ways: an MCP server for agents, a REST API for memory operations, and a GraphQL API for search and structured queries.

One key can open MCP and REST together. GraphQL is different — scope enforcement is deny-by-default, so a key that has any scope ticked is refused everywhere no scope is declared, and GraphQL declares none. Reaching GraphQL means using a key with no scopes selected at all, which is a full-access key. See Which scopes for which job.

Find your address first

Read the address off the Companion Memory app page. Every example in this section writes its base URL as <memory-url> because the real value depends on how your instance runs, and guessing it is the most likely reason a correct config still fails to connect. In the Hub dashboard, open the Companion Memory app and read the Access points card. It lists up to three routes, each with a copy button: Local access, Private VPN, and Public domain. A route that is not set up shows Not configured or Unavailable.

How it runsAddress of the MCP endpoint
Installed from the Hub app store, on the LANhttp://<appliance-ip>:<host-port>/api/mcp (Local access)
Same, from the Hub machine itselfhttp://localhost:<host-port>/api/mcp
Over Tailscale, with Private VPN enabledhttps://<hub>.<tailnet>.ts.net:<host-port>/api/mcp (Private VPN)
Exposed through a public domainhttps://<your-domain>/api/mcp — no port (Public domain)
Desktop app, from the same computerhttp://127.0.0.1:8642/api/mcp
Standalone, from Companion Memory’s composehttp://<host>:8642/api/mcp

<host-port> is the port Hub published for the app. Hub tries the app’s own port, 8642, first, so on a typical install it is 8642. If 8642 is already taken when you install, or you chose a different port in the install form, Hub picks a free port in the 10000–60000 range instead, and the number differs per appliance. Do not assume 8642 — the Local access value is authoritative.

This page does not link a compose file for the Standalone row, and Companion’s source repositories are private. Treat a standalone install as a route for people who already have that compose file; everyone else installs Companion Memory from the Hub app store. The Desktop app serves only on its own computer, always on port 8642 — see Install the desktop app, which has no public download yet. Its window can sign in with a device key that cannot create API keys, so create the key in a browser at that address.

MCP endpoint

URL<memory-url>/api/mcp
TransportStreamable HTTP
Protocol version2025-11-25 (older revisions negotiate down)
AuthAuthorization: Bearer <key> (or x-api-key)
Required scopesMCP to connect, and Intents for tools

The path is /api/mcp. A request to /mcp returns 405 Method Not Allowed — the gateway routes ^/api/mcp only. Older versions of these docs published the wrong path, so if you followed one of those, this is why.

A key with MCP but not Intents connects successfully and shows zero tools. This is the most common problem people hit here, and it is a scope choice rather than a fault. See the scope trap.

REST API

The memory endpoints live under /api/memory and require the Memory scope — which is not the same scope MCP needs. A key minted for MCP alone returns 403 here.

Scope is only half of it: these routes also declare a capability, and falling short of it is a 403 too.

MethodPathPurposeCapability
GET/api/memory/contextBootstrap context for a new sessionread
POST/api/memory/recallRetrieve memories relevant to a queryread
POST/api/memory/writeStore a memory explicitlywrite
POST/api/memory/turnRecord a conversation turnwrite
POST/api/memory/compactCompact accumulated contextwrite

A read key with the Memory scope reaches the first two rows and gets 403 on the other three — “This API key is “read” and cannot perform this action, which requires “write” access”. Scope and capability produce the same status code here, so check both before concluding the scope is wrong.

curl -s -X POST "<memory-url>/api/memory/recall" \ -H "Authorization: Bearer <memory-key>" \ -H 'Content-Type: application/json' \ -d '{"query":"what did I decide about the database migration"}'

GraphQL API

Search and structured queries go through GraphQL rather than a REST search endpoint.

GraphQL declares no scope, and a scope-limited key is refused on every route that declares none. So the MCP + Intents + Memory key the rest of this page tells you to mint is rejected here with “This API key is scope-limited and cannot access this endpoint”. Expect that message inside the GraphQL errors array rather than as an HTTP error status — a client that only checks the status code reads the call as having succeeded. Only a key with no scopes selected reaches GraphQL, and that key is full-access by construction.

# <unscoped-key> = a key created with no scopes ticked. A scoped key is refused here. curl -s -X POST "<memory-url>/api/graphql" \ -H "Authorization: Bearer <unscoped-key>" \ -H 'Content-Type: application/json' \ -d '{"query":"query($input: SearchInput!) { search(input: $input) { __typename } }","variables":{"input":{"query":"database migration"}}}'

There is no /api/search endpoint. Earlier documentation referenced one; it never existed.

Which scopes for which job

What you wantScopes to select
MCP tools onlyMCP + Intents
REST memory onlyMemory
MCP tools and direct REST callsMCP + Intents + Memory
Push observations in (capture agents)Capture
GraphQL, or the graph routesnone — an unscoped, full-access key

The same table as a picture — each surface opens only to the scope it declares, and the unscoped key is the only one that reaches the surfaces that declare none:

This trips people up because MCP and REST are genuinely separate surfaces with separate scopes. If your agent connects over MCP but every /api/memory/* call returns 403, the key is missing Memory.

Capture is the write-only lane for agents that stream observations in and read nothing back — Companion Capture and the browser extension. It opens the /api/v1/* bulk-ingest endpoints (events, communications, contents, things, browser-activity, notes, positions, receipts, product-models, observations, plus inventory, a deprecated alias of things), POST /api/files/upload, and PATCH /api/files/:id. It deliberately opens nothing else — no /api/memory/*, no GraphQL.

Scopes are not additive permissions. Only a handful of surfaces declare a scope — /api/mcp, the intent catalog and the pending-action routes (Intents), /api/memory/*, /api/v1/intelligence/* (persona intelligence) and /api/v1/agent/jobs (all Memory, so a Memory key reaches them too), and the Capture write plane above. Ticking any box makes the key scope-limited, which denies it everywhere no scope is declared: GraphQL, the graph routes, and the batch-upload lane POST /api/v1/files (which, unlike the rest of /api/v1, declares no scope at all — so even a Capture key is refused there). Leaving every box unticked is the only way to reach those, and it produces a full-access key. There is no combination that opens every row above at once.

What an MCP client sees

A key with MCP and Intents sees Companion Memory’s curated intents, which the server advertises as tools. Each intent’s dotted id becomes a tool name with _ in place of the dot, so memory.recall appears as memory_recall. As of v2026.10.2 there are 36:

DomainTools
Memorymemory_recall, memory_remember (needs write), memory_forget (needs full), persona_compose
Knowledgeknowledge_search, knowledge_preferences
People and placespeople_find, people_relationships, places_find, places_next
Calendar and mediacalendar_findEvents, media_findPhotos, media_entertainmentPreferences, media_streamingAvailability
Planningplanning_findTasks, planning_plannedBlocks, planning_createTask, planning_updateTask, planning_scheduleTask, planning_completeTask
Insightsbrief_list, insights_explain, anomalies_recent, forecast_read, forecast_calibration, suggestions_list, suggestions_feedback
Messagingmessaging_listChats, messaging_listMessages, messaging_send
Importimport_list_sources, import_recognize_path, import_scan_inbox, import_run_status, import_import_path, import_import_inbox

Capability filters that list: a read key sees 26 of the 36, a write key sees 32, and a full key sees all 36. memory_forget, messaging_send, planning_completeTask, and import_import_inbox are the four a write key does not get. Even a full key sees those four held for your approval by default — see A full key still waits for Approvals. The server also publishes the catalog itself as the intents://catalog resource. A key with no scopes selected additionally sees a query_* or mutation_* tool for every GraphQL field — 112 queries and 138 mutations as of v2026.10.2.

The operation shape in memory_remember

Every entry in operations is a discriminated union on a kind field. This is now advertised: the tool’s input schema marks kind required and enumerates the four values, and the description repeats them — so an agent working from tools/list alone can construct a valid call. (Earlier releases published operations as an array of open-ended objects, which is why some clients still guess.)

{ "operations": [ { "kind": "entity.create", "entityType": "Concept", "name": "…", "properties": { "category": "…" } } ], "dryRun": true }

entityType is free text as far as the database is concerned (any ^[A-Z][A-Za-z]*$ value inserts), but only the graph’s own vocabulary is browsable afterwards. Stick to Person, Place, Concept, Item, Event, Content, Communication, File, Group, Pet, Product or Tag — an invented type still draws on the Graph view, as a neutral node with the default color and its own legend entry (no type filter can exclude it either), but it is a dead end from there: no explorer type filter maps to it, clicking it resolves to no record so the drawer falls back to the graph’s own name/type card, and the List, Gallery and Map views cannot browse it.

Valid kind values are entity.create, entity.update, relationship.create, and relationship.update. Entity operations are processed before relationship ones within a single call, and a relationship.create can resolve from/to by a name minted earlier in the same batch — but the entity write is queued, not applied inline, so the endpoint check that runs immediately before the relationship is written normally finds nothing yet and the operation fails with “Both source and target entities must exist and be owned by the user”. Create the entities in one call, then relate them in a second. A dryRun returns before that check and so reports the same-call pattern as succeeding.

Use "dryRun": true while you work out the shape; it checks the operation’s shape and reports what would happen without writing anything.

dryRun does not lower the bar to use the tool. memory_remember is a write tool, and capability is checked before the arguments are looked at — so a read key does not see it in tools/list at all, and naming it directly returns “requires “write” access, but this API key is “read"". Experimenting with dryRun needs a write key.

dryRun validates shape, not references. It returns early before the checks that the entities a relationship.create points at actually exist and belong to you, so a dry run can report created for an operation that fails for real. Treat a clean dry run as “the payload parses”, not “the write will succeed”.

Capability

Beyond scopes, each key carries a capability — read, write, or full — that decides what it may do on the surfaces it can reach. See Choosing a capability.

On Companion Memory v2026.9.28.1 and later, capability is enforced on every route a key can reach. A route that declares a level requires it, and one that declares nothing requires what its shape implies — a GraphQL mutation or any non-GET request needs write, and a query or GET needs read. So read holds even for a key with no scopes selected. On earlier releases GraphQL and the batch-upload lane POST /api/v1/files declared neither a scope nor a capability, so a read key with no scopes reached them unchecked; there, select the areas your key needs.

CORS

Companion Memory allows only its own native-app origins by default (capacitor://localhost, ionic://localhost, and bare http://localhost and https://localhost). No browser page on any other origin can call these endpoints, so browser-based MCP clients and browser JavaScript do not work — treat this as a server-side and CLI integration. An operator can add origins with the CORS_ALLOWED_ORIGINS environment variable (comma-separated); it is empty out of the box. The allowed request headers are Content-Type, Authorization, X-CSRF-Token, and X-API-Key, and the only exposed response header is X-Thread-Id, so a browser cannot send or read Mcp-Session-Id even from an allowed origin.

Last updated on