@memco/memcoai
v0.4.0
Published
Node.js SDK for Memco Shared Memory.
Readme
Memco Shared Memory is a persistent, searchable memory that your team and its AI agents share. Agents normally start every conversation from nothing: they rediscover the same constraints, repeat the same mistakes, and lose whatever they worked out the moment the session ends. Shared Memory is where that knowledge goes instead.
You need an account and an API key to use this SDK. Create one at memco.ai.
Install
npm install @memco/memcoaiNode 22 or newer. The package ships both ESM and CommonJS builds with full type
declarations for each, so import and require both work.
import { Memco } from '@memco/memcoai'const { Memco } = require('@memco/memcoai')Quick start
import { Memco } from '@memco/memcoai'
await using client = await new Memco().connect() // reads MEMCO_API_TOKEN
const { domains } = await client.memory.listDomains()
const result = await client.memory.search(
'how should a client authenticate against the memory API',
{ domain: domains[0].slug }
)
for (const memory of result.memories) {
for (const insight of memory.insights) {
console.log(insight.title, insight.updated)
}
}Most work happens inside a session, which relates the searches made for one task so they can be rated together. Bind one, and no later call can drop the handle — a search that silently omits the session id is still a valid search — it opens a session of its own, so the series you were assembling quietly splits in two:
await using client = await new Memco().connect()
await using session = await client.memory.withSession('coding')
const result = await session.search('how does gRPC health checking work')
await session.shareFeedback({
feedback: result.memories.flatMap(memory =>
memory.insights.map(insight => ({
idx: insight.idx,
relevant: true,
correct: true
}))
)
})Rating a single memory rather than a whole batch is shorter directly on the
result: await result.memories[0].feedback({ relevant: true, correct: true })
submits one rating for that memory's own idx, bound to the session it was
found in — no FeedbackRating array to build by hand.
withSession sends nothing until it is awaited, and awaiting it twice returns
the same session rather than opening a second one.
If you would rather not use await using, call close() yourself:
const client = await new Memco().connect()
try {
// ...
} finally {
await client.close()
}Runnable programs are in
examples/.
Agents
A bound session carries the tools a model needs, described in the service's own words rather than each SDK's:
import { briefing } from '@memco/memcoai'
import { createAgent } from 'langchain'
await using client = await new Memco().connect()
await using session = await client.memory.withSession('coding')
// What the service says about the domain the session runs in; null when the
// credential's listing does not describe it.
if (session.domain === null) throw new Error('coding is not listed for you')
const agent = createAgent({
model: 'google_genai:gemini-3.7-flash',
tools: await session.tools().toLangChain(),
systemPrompt: briefing(session.domain, session.instructions)
})toLangChain() needs LangChain installed; the other two shapes are plain data
and need nothing:
const toolset = session.tools()
toolset.toAnthropic() // [{ name, description, input_schema }, ...]
toolset.toOpenAI() // [{ type: 'function', function: {...} }, ...]
// Hand a tool call straight back. OpenAI sends its arguments as a JSON string;
// both that and a plain object are accepted.
// isError is true when the content reports a failure the model caused, for a
// framework that marks a tool result as one.
const { content, isError } = await toolset.call(block.name, block.input)
const result = {
type: 'tool_result',
tool_use_id: block.id,
content,
is_error: isError
}Six operations are offered: memco_search, memco_get_memory,
memco_create_memory, memco_enrich_memory, memco_share_feedback and
memco_revert_memory. listDomains and startSession are deliberately absent —
the session has already answered both, and briefing() says so, so a model is
never handed a tool whose answer it already has.
source is bound rather than exposed, so a model cannot claim a human wrote its
content; sessionId and domain are bound because the session supplies them,
so a tool ignores either if a model sends it.
The descriptions and parameter copy come from tools.json, the same manifest
the hosted MCP server publishes, compiled into the package at build time by
scripts/sync_tool_docs.py. A wording fix belongs upstream, not here.
Only two failures are reported back to the model as text — a malformed
request and a not-found handle, the two it can actually do something about.
Everything else, a rejected credential included, is thrown for your code to
handle. AGENT_RECOVERABLE names that line.
call() returns a ToolResult, { content, isError }: content is the text
for the model, and isError is true for those two failures and for a tool
name that is not offered. Tool and Toolset both return it. The tools
toLangChain() builds carry it too — a tool message for such a failure has
status: 'error', with the same content.
Configuration
Arguments win over the environment, which wins over the defaults.
| Setting | Option | Environment | Default |
| --------------------------------- | --------------- | ----------------------------------- | ------------------------------------------------------ |
| Credential | token | MEMCO_API_TOKEN | required, unless an API client's credentials are given |
| API client id | clientId | MEMCO_CLIENT_ID | — |
| API client secret | clientSecret | MEMCO_CLIENT_SECRET | — |
| Issued token lifetime, in seconds | tokenLifetime | — | the service's |
| Endpoint | host | MEMCO_API_HOST | grpc.memco.ai:443 |
| TLS | tls | MEMCO_API_TLS (true or false) | true |
| Deadline | timeout | — | 30 seconds |
| Log level | logLevel | MEMCO_LOG | info |
The credential is either a Memco API key or a session token issued for your
account; both go in the same header. MEMCO_API_KEY is still honoured but
warns.
The timeout — the client's, or one passed to a single call — is one deadline
for the whole call. If the call first has to wait for a credential, the
client's first token or a new key once the current one has expired, that wait
counts against it and the RPC has what is left. startSession and
importMemories, which make several RPCs, apply it to each of them.
TLS is on unless you turn it off, with tls: false or MEMCO_API_TLS=false,
for a plaintext endpoint such as a local development server. Any other value of
MEMCO_API_TLS is refused rather than guessed at. A client built without TLS
says so at WARNING as it is constructed, since its credential then crosses
the wire readable.
A client can authenticate as an API client instead, with the clientId and
clientSecret it was created with. The SDK exchanges them for a token, asking
for tokenLifetime if you set it, and renews the token before it expires. The
two are used together, and which credential a client holds is decided in this
order:
- A
tokenoption wins, and the client variables are ignored. Passing it besideclientIdorclientSecretis an error. clientIdandclientSecretoptions come next.- With no credential option,
MEMCO_CLIENT_IDandMEMCO_CLIENT_SECRETwin overMEMCO_API_TOKENwhen both are set. - Otherwise
MEMCO_API_TOKEN, as above.
Half a pair, in options or in the environment, throws MemcoConfigError, as
does a blank option, or tokenLifetime without client credentials. A blank
environment variable reads as unset.
Constructing a Memco sends nothing. connect() makes two calls. It probes the
service's health endpoint, so a bad host, port or TLS setting fails immediately
rather than on your first real call; that probe carries no credential, so it
cannot check one. It then calls listDomains, which does — a bad token fails
here — and which reports the input limits the service enforces. The client keeps
those and applies them from then on, so an oversized field is refused locally
instead of costing a round trip.
With client credentials, the second call is the exchange for a token instead,
so bad credentials fail in connect() too. An issued token carries no content
role of its own, so the service refuses memory calls made with it. Such a
client administers networks and users, and reaches memory through sessions
acting for your users, both described below.
connect() is safe to call again after a failure; the channel is kept, so a
retry is cheap rather than a fresh handshake.
Building an integration
A package that calls Memco on its users' behalf, such as a framework's memory provider, names itself so the service can tell its traffic apart:
const client = new Memco({
integration: { name: 'memco-eve', version: '0.2.0' }
})Every call's user agent then starts memco-node/<sdk version> memco-eve/0.2.0,
with the SDK's own token still first. The name and the version are each 1 to 64
letters, digits, ., _ or -, and the version may also carry + for build
metadata; anything else throws MemcoConfigError. No environment variable sets
it, since it identifies the calling code rather than the deployment, and an
application calling Memco directly leaves it unset.
Against a local development server
A local server usually serves plaintext, so point the client at it and turn TLS off, either in the environment:
export MEMCO_API_HOST=localhost:50052 MEMCO_API_TLS=false MEMCO_CLIENT_ID=... MEMCO_CLIENT_SECRET=...
node build/js/examples/map_your_users.jsor in code:
const client = await new Memco({
host: 'localhost:50052',
tls: false,
clientId: '...',
clientSecret: '...'
}).connect()Either way the client says so before it sends anything:
2026-09-25T10:02:11.417Z memco.client WARNING TLS is off: localhost:50052 is dialled in plaintext, credentials includedA token issued for the live service is rejected by a local server, so for the
token examples set MEMCO_API_TOKEN to one the local server issued.
Logging
Everything the SDK logs is written to stderr under a name beginning memco. —
memco.client, memco.config, memco.validate — so a record
says which part of the SDK spoke.
2026-08-31T12:00:00.000Z memco.client INFO connected to grpc.memco.ai:443 (tls=true)
2026-08-31T12:00:00.120Z memco.client DEBUG Search ok in 118ms
2026-08-31T12:00:00.120Z memco.validate DEBUG tags trimmed from 62 to 50 by the service's capThat last record is the only signal that the service's cap dropped some of what
you sent, so debug is worth turning on when a call does less than you expect.
No credential is ever written to a record — not a token, not a client
secret, and not the key a session acting for one of your users holds. The
DEBUG lines report where a credential came from, never what it was, and a key
is named by its id alone.
MEMCO_LOG accepts critical, error, warning, info, debug, or none
to silence the SDK. A value it does not recognise warns and falls back to
info, because a mistyped environment variable should not stop a program
starting; a logLevel option it does not recognise throws, because that is your
own code.
If your application configures its own logging, prefer MEMCO_LOG=none over
logLevel: 'none': it says the same thing without a library reaching into
process-wide state on the application's behalf.
Operations
Reached as client.memory, or on a session without the session/domain arguments.
| Operation | What it does |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| listDomains() | The domains this credential may name, and the limits in force |
| startSession(domain, options) | Open a session and return it right away — the same bound operations withSession yields; externalId acts for one of your users |
| withSession(domain, options) | Open one lazily — await it once for the same disposable session, however many times it's referenced |
| resumeSession(sessionId, options) | Take up a session opened earlier, by its id — refused as not found when the id is unknown or not the caller's |
| getSession(sessionId, options) | The domain a session runs in, when it started, and its counts of searches, ratings and writes |
| search(query, options) | Find existing knowledge |
| getMemory(idx, options) | Fetch one memory in full |
| createMemory(options) | Save new knowledge |
| enrichMemory(options) | Add to a memory a search returned, or open a new one |
| shareFeedback(options) | Rate what a search returned |
| revertMemory(operationId) | Undo one of your own writes |
| importMemories(memories, options) | Contribute many at once |
importMemories splits a batch longer than the service accepts across several
calls and renumbers every outcome back into the array you passed, so
outcome.index always indexes your own input whatever the service's limit is.
A session outlives the process that opened it. Keep its id, and resume it where the work continues — on an agent's next turn, say — so the searches stay one series:
const sessionId = session.id // stored with the conversation
// Later, in this process or another:
await using resumed = await client.memory.resumeSession(sessionId)
resumed.domain?.slug // the domain it was started in; resuming cannot change it
const info = await resumed.getSession() // info.searches, info.feedbacks, ...Resuming starts nothing. The service looks the session up first, so an id that
is unknown or not the caller's fails with MemcoNotFoundError there rather than
at the first search. A resumed session carries no opening instructions; they
were given when it started. See
examples/resume_session.ts.
Networks and users
A client built with an API client's credentials administers your organization:
its memory networks, which scope what the people placed in them can find, and
its external users — your own users, known to Memco by your id for them. The
API client needs the network-management and user-management scopes.
import { Memco } from '@memco/memcoai'
await using client = await new Memco({ clientId, clientSecret }).connect()
const [root] = (
await client.networks.list({ parentId: 'root', domain: 'coding' })
).networks
const acme = await client.networks.create({
name: 'Acme',
parentIds: [root.id],
scope: 'customer'
})
const user = await client.users.create('customer-42', {
roles: ['creator'],
name: 'Ada'
})
await client.networks.addMember(acme.id, { userId: user.id })| Method | Purpose |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| networks.list(options) | One page of networks, filtered by name, scope, owner, domain, parent or id |
| networks.create(options), update(id, options), delete(id) | Manage a network; deleting one takes everything placed in it |
| networks.listMembers(id), addMember(id, options), removeMember(...) | Who is placed in a network |
| networks.listGroups(...), listGroupMembers(...), addGroup(...), removeGroup(...) | Identity-provider groups, for enterprise organizations |
| users.list(options), get(externalId), create(...), update(...), delete(...) | Manage external users |
| users.listKeys(externalId), createKey(...), deleteKey(...) | An external user's API keys |
| networks.createOrGet(options), users.createOrGet(...) | Create, or return what already holds the name or id; safe when two callers race |
As everywhere in this SDK, at most the first argument is positional — the
network, group or user a method acts on — and everything else, required or
not, goes in one options object: addMember(networkId, { userId, force }),
deleteKey(externalId, { keyId }).
An external user has no sign-in of its own, never holds admin, and can be
placed only in a customer network. Every client.users method names the user
by your externalId; addMember takes Memco's own user.id. A user is placed
in one network per domain: placing them in a second is refused with
MemcoUserAlreadyAssignedNetworkError, naming the network they are in, unless
you pass force: true, which moves them and reports where from in
movedFrom. On update, a field left out stays as it is, and an empty string
clears it. Pages count from 1. A key's value is returned once, by createKey,
and never again — and never by console.log or JSON.stringify of the result
either: read created.value where you mean to.
Acting for your users
Passing externalId to startSession, withSession or resumeSession opens a session that
acts as one of your external users, so what it finds is what that user may
find, and what it writes is that user's:
await using client = await new Memco({ clientId, clientSecret }).connect()
await using session = await client.memory.withSession('coding', {
externalId: 'customer-42'
})
const result = await session.search('how should a client authenticate')Opening one runs in a fixed order. The SDK has the service mint an impersonation key for the user, under your client's token; lists the domains under that key, so the session learns the limits and any deprecation notice that apply to the user; and only then starts the session. Every call made through the session — its tools, and the memories it returns, included — carries that key, and never your client's token. The API client needs the admin grant, and what the session can reach is scoped by the network the user is placed in.
Close the session. Leaving the await using block, or calling
session.close(), ends the key on the service. The service caps how many live
keys each user may hold, so a session left open holds one of them until its key
expires. A session dropped without closing has its key ended eventually — once
the session has been garbage-collected, by the next call the client makes — but
closing ends it at once, and is the only way that says when. After closing, a
call through the session throws MemcoConfigError. A session opened without
externalId holds nothing, so closing it changes nothing.
If the service cannot end a key when its session closes, a warning names the key by its id, never its value, and the client tries again before it next opens a session for that user, and when it closes. Closing the client ends every key still live; one it cannot end is logged the same way and expires on its own.
Keys renew themselves. A key is replaced once four fifths of its lifetime
have passed. The lifetime is the one the service reports for the key, counted
from before it was asked for on this machine's monotonic clock, so a skewed
wall clock cannot move it; with a service too old to report it, the key's
expiry time is read against the wall clock instead, and a key that arrives
already expired by it is refused with a MemcoConfigError naming the clock.
The key a renewal replaces is ended as soon as no call is still using
it, so a long session never fails for an expired key, and a renewal never
revokes one under a call in flight. The renewal runs in the background: the
calls that find the key due go on under it, since it has not expired, and the
calls after the renewal lands carry the new one. If the renewal fails — the
user already holds as many live keys as the service allows, say — the failure
is logged once at WARNING, naming the key by its id, and the next call past
the renewal point tries again. Only a call with no key to use — the first, or
one after the key has expired — waits for a new one, and that wait counts
against its own timeout; calls waiting together share one attempt and its
failure. The
client's own token renews the same way.
Each session carries its own key, so sessions for different users run side by side on one client:
async function answer(user: string, question: string): Promise<void> {
await using session = await client.memory.withSession('coding', {
externalId: user
})
const result = await session.search(question)
// ...
}
await Promise.all([
answer('customer-42', 'how do I rotate an API key'),
answer('customer-7', 'why was my import rejected')
])Errors
Every failure is typed. No raw transport error reaches a caller.
MemcoError
├── MemcoConfigError bad settings, or a closed client or session
└── MemcoAPIError the service reported a failure
├── MemcoAuthenticationError UNAUTHENTICATED
├── MemcoPermissionError PERMISSION_DENIED
├── MemcoInvalidRequestError INVALID_ARGUMENT — also raised locally
├── MemcoNotFoundError NOT_FOUND
├── MemcoAlreadyExistsError ALREADY_EXISTS
├── MemcoPreconditionFailedError FAILED_PRECONDITION — read `.metadata`
│ ├── MemcoSunsetError a version reached its end of life
│ ├── MemcoUserAlreadyAssignedNetworkError
│ │ already in another network of the domain
│ └── MemcoExternalUserNeedsCustomerNetworkError
│ only a customer network takes an external user
├── MemcoResourceExhaustedError RESOURCE_EXHAUSTED — read `.kind`
├── MemcoUnavailableError UNAVAILABLE
│ └── MemcoUnhealthyError answered, but not serving
├── MemcoTimeoutError DEADLINE_EXCEEDED
└── MemcoInternalError everything elseSome subclasses sit under classes you may also want to catch, so order your
checks with the specific one first: MemcoSunsetError and the two placement
refusals before MemcoPreconditionFailedError, and MemcoUnhealthyError
before MemcoUnavailableError.
A precondition failure carries the detail the service attached as
error.metadata, a frozen object that is empty when there is none. The two
placement refusals also read it for you: currentNetworkId and
currentNetworkName on MemcoUserAlreadyAssignedNetworkError, and
requiredNetworkScope on MemcoExternalUserNeedsCustomerNetworkError. Which
of them a refusal is comes from the reason the service names, never from its
message.
Prefer error.name over instanceof if your dependency tree might load both
the ESM and the CommonJS build — two copies of a class are two identities,
whereas error.name === 'MemcoNotFoundError' holds either way.
Some limits are checked locally. Once connect() has run, an over-long
query or an over-large batch is refused before anything is sent, with the same
MemcoInvalidRequestError the service would have returned.
Not every not-found is an error. A revert reports what it actually removed:
const reverted = await client.memory.revertMemory(created.operationId!)
if (reverted.outcome === RevertOutcome.EXPIRED) {
console.log('outside the revert window')
}Provenance
Which version of the service contract this package was generated from:
import { provenance } from '@memco/memcoai'
provenance().serverCommit // '93ec8030bac33afa1bf2aa376d5f5a406098d81a'
provenance().protos.map(proto => proto.path)
// ['memcoai/admin/v1/admin.proto', 'memcoai/auth/v1/auth.proto', 'memcoai/memory/v1/memory.proto']Contributing
See CONTRIBUTING.md.
make -C nodejs install
make -C nodejs test
make -C nodejs docs-serveEverything under client/ is generated from the service contract and is
replaced wholesale each time it is regenerated, so please do not edit it by
hand.
Licence
MIT, © Memco Labs, Inc.
