xmemory
v3.13.0
Published
xmemory
Readme
xmemory
TypeScript/JavaScript client library for the xmemory API.
Installation
npm install xmemoryThe package ships both ESM and CommonJS builds. import and require both
work, and TypeScript resolves the matching declarations either way.
Quick start
import { XmemoryClient } from "xmemory";
const xm = new XmemoryClient({
url: "https://api.xmemory.ai", // or set XMEM_API_URL env var
apiKey: "<your-api-key>", // or set XMEM_API_KEY env var
});
// Write and read from an existing instance
const inst = xm.instance("<your-instance-id>");
await inst.write("Alice is a software engineer who loves TypeScript.");
const result = await inst.read("What does Alice do?");
console.log(result.reader_result);Configuration
| Parameter | Env var | Default | Description |
|-------------|------------------|---------------------------|-----------------------------------------|
| url | XMEM_API_URL | https://api.xmemory.ai | Base URL of the xmemory API |
| apiKey | XMEM_API_KEY | undefined | Bearer API key for authentication |
| timeoutMs | — | 60000 | Default request timeout in milliseconds |
The legacy token option and XMEM_AUTH_TOKEN env var are still accepted for backwards compatibility but are deprecated and will be removed in a future release. Using them prints a deprecation warning. If both the new and legacy values are set, the new ones win.
Creating a client
import { XmemoryClient, xmemoryInstance } from "xmemory";
// Option 1: constructor (no health check)
const xm1 = new XmemoryClient({ apiKey: "..." });
// Option 2: factory with health check
const xm2 = await XmemoryClient.create({ apiKey: "..." });
// Option 3: convenience function (same as Option 2)
const xm3 = await xmemoryInstance({ apiKey: "..." });Admin operations
All cluster and instance management lives under client.admin.
Clusters
const clusters = await xm.admin.listClusters();
const cluster = await xm.admin.getCluster(clusterId);Create an instance
import { SchemaType } from "xmemory";
const inst = await xm.admin.createInstance(
clusterId,
"my-instance",
schemaYml,
SchemaType.YML,
{ description: "User profiles" },
);
// inst is an InstanceHandle — use it directly for data operations
await inst.write("Alice joined the team.");List and get instances
const instances = await xm.admin.listInstances();
const info = await xm.admin.getInstance(instanceId);Schema operations
const schema = await xm.admin.getInstanceSchema(instanceId);
await xm.admin.updateInstanceSchema(instanceId, newYml, SchemaType.YML);Generate schema
const result = await xm.admin.generateSchema(clusterId, "Track user profiles and preferences");
console.log(result.data_schema);Update metadata and delete
await xm.admin.updateInstanceMetadata(instanceId, "new-name", "new description");
// Change one field and leave the rest alone
await xm.admin.patchInstanceMetadata(instanceId, { description: "a new description" });
const deletedIds = await xm.admin.deleteInstance(instanceId);Agent-facing instance metadata
An instance can carry metadata that shapes how agents connect to it and what
they do with it. patchInstanceMetadata is the way to set the advisory hints:
every option is independent, omitting one leaves the stored value
untouched, and passing null clears it.
import { AgentSurface, BindingTier } from "xmemory";
await xm.admin.patchInstanceMetadata(instanceId, {
// Advisory hints — they seed what a connect flow proposes, and grant nothing.
agentSurfaces: [AgentSurface.CLAUDE_CODE, AgentSurface.CODEX],
agentDefaultBindingTier: BindingTier.AUTOLOAD,
agentEngagementHints: ["a convention is learned or corrected"],
});Concurrent edits to these three are last-writer-wins by design: they only seed
what a connect flow proposes, so the loser of a race re-applies a suggestion.
agentOwnerInstructions is not like that — see below.
Reading it back:
const info = await xm.admin.getInstance(instanceId);
info.agent_owner_instructions;
info.agent_surfaces; // e.g. ["claude_code", "codex"]
info.agent_default_binding_tier; // e.g. "autoload"
info.agent_engagement_hints;These read as plain strings rather than a narrow union, so a value your server knows and this release does not is returned rather than making the instance unreadable.
Setting the standing instructions. Use updateInstanceMetadata for
agentOwnerInstructions, not patchInstanceMetadata. The field is rendered to
agents verbatim, and a second writer edits it from the same screen, so a
silently lost edit is a rule that stops being enforced. Only
updateInstanceMetadata carries expectedOwnerInstructionsEpoch: pass the
epoch you read the value at and the server refuses the losing save instead of
applying it:
const info = await xm.admin.getInstance(instanceId);
await xm.admin.updateInstanceMetadata(instanceId, info.name, info.description ?? "", {
agentOwnerInstructions: `${info.agent_owner_instructions ?? ""}\nAlso: never paraphrase a rule.`,
expectedOwnerInstructionsEpoch: info.agent_owner_instructions_epoch,
});patchInstanceMetadata also accepts the field — it is the only way to set it
without restating the name — but it can carry no guard, so an edit composed
from stale data overwrites a newer one silently. Reach for it only when you are
seeding a value nobody else is editing.
Instance data operations
Get a handle to an instance and use it for reads, writes, and extractions.
const inst = xm.instance("<instance-id>");inst.write(text | mutations, options?) → WriteResult
Extract and store structured objects from text:
const result = await inst.write("Bob is a designer based in Berlin.");
console.log(result.write_id, result.trace_id);
console.log(result.changes); // what the write created / updated / deleted
console.log(result.console_url); // this operation's trace in the console, or nullEvery data operation — read, write, writeAsync, writeStatus, extract — carries
console_url, the deep link to that call's trace. It is per operation rather than per
record, and null when the server has no console configured.
Options: { extractionLogic?, diffEngine?, scope?, timeoutMs? } — extractionLogic
defaults to "fast"; see Scoped writes for scope.
Or pass a WriteMutation[] for structured writes — deterministic, LLM-free
create/update/delete mutations applied in array order (later mutations may
reference objects created earlier in the batch; a null value in values
clears that field):
const result = await inst.write([
{
object_mutation: {
object_type: "person",
create: { key: { name: "Bob" }, values: { role: "designer" } },
},
},
{
object_mutation: {
object_type: "person",
update: { key: { name: "Bob" }, values: { role: null } }, // null clears
},
},
]);Options for the mutations form: { timeoutMs? } (extraction options don't apply).
inst.writeAsync(text | mutations, options?) → AsyncWriteResult
Start an asynchronous write (same text / WriteMutation[] dual input and the
same options as inst.write, scope included). Returns a write_id for
tracking; a scope violation is reported by inst.writeStatus as a failed write.
const { write_id } = await inst.writeAsync("Carol manages the London office.");inst.writeStatus(writeId, options?) → WriteStatusResult
Poll the status of an async write.
const status = await inst.writeStatus(write_id);
console.log(status.write_status); // "queued" | "processing" | "completed" | "failed" | "not_found"inst.read(query, options?) → ReadResult
Query the instance.
const result = await inst.read("Who is on the team?");
console.log(result.reader_result);Options: { readMode?, scope?, includeRelatedTypes?, relatedTypesDepth?, skipSuggestionCapture?, traceId?, timeoutMs? } — readMode defaults to "single-answer".
What comes back
In "single-answer" mode reader_result is always the prose answer. In
"raw-tables" and "xresponse" mode its value says which of four answers the
read gave:
| reader_result | Meaning | What to do |
| --- | --- | --- |
| rows | Answered. | Use them. |
| exactly { columns: [], rows: [] } / { objects: [], relations: [] } | The query executed and matched nothing — every table and column it used exists, so the data is absent. | Trust the empty result. |
| null | The schema provably cannot represent the concept. An answer, not a variant of the empty one. | Try a better-matching instance; this memory cannot hold it. |
| (throws) | Every sub-query's SQL failed, so nothing was answered. | Catch XmemoryAPIError with status 422 and code "INVALID_INPUT". It is the same answer for the same input, so do not retry it. |
const result = await inst.read("Which invoices are overdue?", { readMode: "raw-tables" });
if (result.reader_result === null) {
// Not a concept this memory holds — try another instance.
} else {
const { rows } = result.reader_result as { columns: string[]; rows: unknown[][] };
// rows.length === 0 means the data is absent, not that the read failed.
}Composite queries
When a query bundles several independent questions, the server may decompose it
into sub-queries and answer each one. reader_result is still the combined
answer (for "single-answer" mode, a labelled multi-part string); reader_results
holds one TaggedReaderResult ({ sub_query, reader_result, error }) per
sub-query so you can read each answer unambiguously — a single-intent query
decomposes to one entry. reader_results is always an array: a server without
question decomposition omits the field on the wire and the client normalizes it
to [], so it is empty regardless of how many questions the query held.
const result = await inst.read("Who leads sales, and where is HQ?");
for (const part of result.reader_results) {
console.log(part.sub_query, "→", part.error ?? part.reader_result);
}Each part's reader_result uses the same four answers as above, with one more
case: a sub-query whose SQL failed carries the empty result and a user-safe
error, while the others are answered regardless. Its bytes then equal those
of a sub-query that matched nothing, so read error before reader_result, as
the loop above does. The combined reader_result folds the parts: rows if any
sub-query answered; else the empty result if any executed and matched nothing;
else null. In the tabular modes a read where every sub-query failed throws
the 422 above instead.
Scoped reads
By default a read may draw on the whole instance. Pass a scope to restrict it
to a set of concrete objects — useful for grounding an answer in exactly the
records you care about, or for keeping a per-user / per-entity read from leaking
into unrelated data.
Each object in the scope is identified by its type (the PascalCase class name
or snake_case table name) plus its user-defined primary key, a mapping of
primary-key field name to value. Only objects of a type that has a user-defined
primary key can be scoped:
const result = await inst.read("What do we know about these people?", {
scope: {
objects: [
{ type: "Person", key: { full_name: "Alice Smith" } },
{ type: "Person", key: { full_name: "Bob Jones" } },
],
relationsScope: "all_relations", // default: "no_relations"
},
});relationsScope controls relation traversal: "no_relations" (the default)
restricts the read to the listed objects only, while "all_relations" also
exposes the relations among the in-scope objects.
Related types
A read can also say what else the memory could answer about. Pass
includeRelatedTypes: "types" and related_types on the result names the
object types the read touched, each with the fields it did not return and the
object types a declared relation links it to (the relation, both roles, and its
cardinality seen from the touched type), plus a catalog that describes every
named type (description, primary key, field names) and relation once. It is
derived from the instance schema and the statements the read executed — no
extra rows, no model call — so an agent can ask a deliberate follow-up instead
of guessing what the store holds.
const result = await inst.read("Which courses require an English test?", {
includeRelatedTypes: "types",
});
for (const touched of result.related_types?.touched ?? []) {
console.log(touched.object_type, "did not return", touched.fields_not_returned);
for (const link of touched.related) {
const neighbour = result.related_types!.objects[link.object_type];
console.log(" linked to", link.object_type, "via", link.relation, neighbour.fields);
}
}related_types is null unless the read asked for it. A requested read that
executed nothing arrives with touched: []. The server caps the payload;
truncated says when it did, and omitted_touched and each entry's
omitted_related count what was dropped.
To follow the relations further, add relatedTypesDepth: 2 (up to 3). Every
entry under related_types.objects then carries its distance from the touched
types (0 for a touched type) and, between the touched types and the last level,
its own related edges, so the walk can be continued from the catalog:
const result = await inst.read("Which courses require an English test?", {
includeRelatedTypes: "types",
relatedTypesDepth: 2,
});
for (const [name, entry] of Object.entries(result.related_types?.objects ?? {})) {
console.log(name, "at distance", entry.distance);
for (const link of entry.related) {
console.log(" linked to", link.object_type, "via", link.relation);
}
}related_types.depth echoes the depth asked for. distance counts listed edges
from the nearest touched type, and omitted_objects counts the types within that
many relation levels that the server's budgets kept out of the catalog; truncated
says something was cut. Left unset, nothing is sent and the server serves one
level in the shape it always had: depth, omitted_objects and the catalog
entries' distance, related and omitted_related are then absent. A value
outside 1 to 3 is a 422 whose code is VALIDATION_ERROR. Asking for related
types needs the
instance.get_own permission on the API key, the same one the schema
endpoints need, on top of data.read: a key without it gets a 403 whose
message names the permission, and the plain read is unaffected.
Keeping a read out of schema suggestions
Every read feeds the suggestion engine:
after answering, the server asks a model whether the schema could fully answer
the question, and a gap becomes a proposed schema change. For programmatic reads
— high-volume polling, read-your-write checks — whose question does not reflect
what a person wants from the memory, pass skipSuggestionCapture: true. The
answer is the same; the read proposes nothing and costs no judge call.
const result = await inst.read("Does invoice INV-42 exist?", { skipSuggestionCapture: true });Scoped writes
A write is normally free to touch anything in the instance: the extractor sees
the text alone, and whatever it produces is reconciled against the whole
instance. Pass a scope to anchor a text write to a set of concrete existing
objects instead — the same ScopeObject shape as a scoped read:
const result = await inst.write(
"After her promotion she is a surgeon, and her desk phone is +1-555-0100.",
{ scope: { objects: [{ type: "Person", key: { full_name: "Alice Smith" } }] } },
);This does two things at once. The scoped objects' current values are shown to the extractor, so the new information is folded into them instead of producing a near-duplicate record. And the write is then confined to the scope: it may only modify or delete the scoped objects, and create new objects and relations anchored to them. A write that would touch any other existing object fails with a validation error rather than applying partially — that confinement is checked against the resulting plan, so it holds regardless of what the extractor produced.
A write scope takes the same ScopeObjects as a read scope, identified the same
way. Unlike a read scope there is no relationsScope: the relations among the
scoped objects always accompany the extraction hint.
Things to know before reaching for it:
- Scope applies to text writes only. The
WriteMutation[]overload takes{ timeoutMs? }, so passing a scope alongside structured mutations does not compile — those bypass extraction entirely and leave a scope nothing to anchor to. - Only objects of a type with a user-defined primary key can be scoped. A
scope names records by that key, so a type declared
primary_key: []has nothing to name its records by. - The server currently accepts a scope with fast extraction only, and caps
the number of scoped objects per write. Both are server-side rules, so they
surface as an
XmemoryAPIError. - A scoped write additionally requires read permission on the instance, because the scoped objects' current values are shown to the extractor. An API key with write access alone is refused.
inst.extract(text, options?) → ExtractResult
Extract objects from text without storing them.
const result = await inst.extract("Dave is an engineer in Tokyo.");
console.log(result.objects_extracted);inst.getSchema(options?) → InstanceSchemaInfo
const schema = await inst.getSchema();
console.log(schema.data_schema);inst.describe(options?) → DescribeResult
Get the agent-facing tool descriptions for an instance, with its schema in schemaSummary.
const desc = await inst.describe();
console.log(desc.asText()); // plain text for system prompts
const tools = desc.asAnthropicTools(); // Anthropic tool-use format
const tools = desc.asOpenaiTools(); // OpenAI function-calling format
desc.purpose; // what the memory is for (the instance description)
desc.ownerInstructions; // the standing preference set for it, verbatim
desc.usageBrief; // generated from the schema; null until generatedasText() includes purpose and ownerInstructions when the instance has
them. usageBrief is left out of it — it restates the schema summary that is
already there — so read the property if you want it.
Both fields are free text set by anyone holding edit permission on the instance,
so asText() labels each with where it came from rather than presenting it as
the library's own words. Those labels state provenance; they are not a security
boundary. If you inject this into a system prompt you are still handling text you
do not control.
Results are cached for 5 minutes. Call inst.clearDescribeCache() to force a refresh.
Schema evolution
Schemas can change after creation. xmemory supports safe, data-preserving migrations (rename / remove / type change) driven by structured migration ops, plus a suggestion engine that proposes improvements from real read traffic. This is purely additive — existing methods are unchanged.
See the Schema evolution section of the API reference for the conceptual model, and the TypeScript guide for full walkthroughs.
Suggestion-engine flow (review → decide → apply)
The engine surfaces a single rolling proposal per instance. The minimum flow is three calls — review, decide (in bulk), apply:
import { XmemoryClient, type DecisionInput } from "xmemory";
const xm = new XmemoryClient({ apiKey: "..." });
const inst = xm.instance("<instance-id>");
// 1. Review — get the proposal + its concurrency token.
const review = await inst.reviewSuggestions();
if (review.status === "evolution_in_progress") {
console.log(`A migration is in flight; retry in ${review.retry_after_seconds}s`);
} else if (review.proposal) {
const proposal = review.proposal;
for (const item of proposal.items) {
console.log(item.item_fingerprint, item.rationale, item.op);
}
// 2. Decide — accept / reject / defer per item, in one batch.
const decisions: DecisionInput[] = proposal.items.map((item) => ({
item_fingerprint: item.item_fingerprint,
decision: "accept",
}));
const decided = await inst.decideSuggestions(proposal.proposal_version, decisions);
// 3. Apply — commit accepted decisions as one migration.
const applied = await inst.applyPendingDecisions(decided.next_proposal_version);
console.log(applied.status, applied.summary); // e.g. "ok" "added 1 field"
}When status === "evolution_in_progress", back off for retry_after_seconds
and retry instead of blocking.
Direct migration flow (enhance → dry-run → update)
Drive a migration yourself — ask the server to enhance the current schema, preview the DDL, then apply it:
import { XmemoryClient, SchemaType } from "xmemory";
import yaml from "js-yaml";
const xm = new XmemoryClient({ apiKey: "..." });
const current = (await xm.admin.getInstanceSchema("<instance-id>")).data_schema;
// 1. Enhance — new schema + an executor-ready migration plan.
const enhanced = await xm.admin.enhanceSchema(
"<cluster-id>",
"Rename Person.mail to Person.email.",
yaml.dump(current),
);
console.log(enhanced.summary, enhanced.migration_plan?.ops);
const newYaml = yaml.dump(enhanced.data_schema);
// 2. Dry-run — preview the DDL without applying anything.
const preview = await xm.admin.dryRunMigration("<instance-id>", newYaml, SchemaType.YML, {
migrationPlan: enhanced.migration_plan ?? undefined,
});
console.log(preview.statements);
// 3. Update — apply. confirmDestructive is required for ops that drop data.
const info = await xm.admin.updateInstanceSchema("<instance-id>", newYaml, SchemaType.YML, {
migrationPlan: enhanced.migration_plan ?? undefined,
confirmDestructive: false,
});
console.log(info.migration_id, info.prior_version, "->", info.new_version);Migration history
const page = await xm.admin.listMigrations("<instance-id>", { limit: 20 });
for (const record of page.items) {
console.log(record.id, record.source, record.prior_version, "->", record.new_version);
}
const detail = await xm.admin.getMigration("<instance-id>", "<migration-id>", { includeYaml: true });
console.log(detail.yaml_before, detail.yaml_after);Migration ops are exported as discriminated-union types (MigrationPlan,
MigrationOp, AddField, RenameField, RemoveObject, …) keyed on op_type.
ProposalItem.op and MigrationRecord.ops are raw dicts for forward
compatibility — narrow them to MigrationOp when needed.
Runnable end-to-end examples live in examples/.
Connecting an instance elsewhere
admin.getSetupInstructions(instanceId) and instance.setupInstructions() both return an
AgentSetupResult: how to reach the same memory from another agent surface, ordered
most-likely-first. Available on either handle, because the MCP instance connection serves
the same tool.
const setup = await inst.setupInstructions();
for (const surface of setup.surfaces) {
console.log(surface.label);
for (const step of surface.steps) console.log(" ", step.description, step.command ?? "");
}Two formats. The default, SetupFormat.AGENT, answers what do I run right now, here.
SetupFormat.PROJECT also returns the files a team commits once, so nobody sets the
instance up by hand:
const setup = await inst.setupInstructions({ format: SetupFormat.PROJECT });
if (setup.format === SetupFormat.PROJECT) {
for (const f of setup.project?.fragments ?? []) {
console.log(f.path, f.merge); // a merge, never a file to overwrite
}
}Read setup.format rather than assuming. A server older than that parameter ignores
it, answers 200, and names no format at all — so undefined means this deployment
predates the project rendering. That is a deliberate difference from the Python client,
whose model applies an AGENT default; there is no runtime normalization point here, and
inventing one would report a format the server never claimed.
Nothing returned carries a credential: the steps tell a reader to sign in themselves, out of band, so an instance id stays an identifier rather than a key.
Advisory values — step.kind, fragment.merge, format — are widened to accept a value
added to the server after this release, so an additive change does not become a breaking
one. A step.kind you do not recognise is not something to execute.
Request identification
Every request this client issues carries an X-Xmemory-Client header naming the package and its
release, followed by the host — for example xmemory-node/3.9.0 (node v24.5.0; darwin). The
parenthetical reports process.version and process.platform, says unknown for either one a host
does not supply, and never carries a hostname. The API uses it to tell its own clients apart.
The identity travels in a dedicated header rather than in User-Agent, which belongs to the runtime:
your User-Agent is neither read nor written, and every other header on the request is left alone. A
proxy that strips unknown X- headers costs the attribution but not the request — the call still goes
through, counted as a generic caller.
Error handling
All errors throw XmemoryAPIError. Health check failures throw XmemoryHealthCheckError (a subclass).
import { XmemoryClient, XmemoryAPIError, XmemoryHealthCheckError } from "xmemory";
try {
const xm = await XmemoryClient.create({ apiKey: "..." });
} catch (e) {
if (e instanceof XmemoryHealthCheckError) {
console.error("Server unreachable:", e.message);
}
}
try {
await inst.read("query");
} catch (e) {
if (e instanceof XmemoryAPIError) {
console.error(`API error (HTTP ${e.status}): ${e.message}`);
}
}XmemoryAPIError carries status (HTTP status), code (structured error code,
when the server returned one), details (an optional structured payload), and
retryAfter (the Retry-After header in seconds, when the server sent one).
Branch on code, not on the bare HTTP status — the same status can mean
different things:
| HTTP | code | Meaning | Retryable? |
| ---- | ----------------- | ---------------------------------------------------- | ---------------------------------- |
| 402 | QUOTA_EXCEEDED | Tenant exhausted its plan/usage allowance (a daily or monthly token quota). | No. Wait for the window to reset. |
| 429 | RATE_LIMITED | Genuine velocity / rate limit. | Yes, with backoff. |
| 422 | INVALID_INPUT | A read that answered nothing: in "raw-tables" / "xresponse" mode every sub-query's SQL failed, or the model provider declined the input. See What comes back. | No. Same input, same answer. |
| 403 | FORBIDDEN | The API key lacks a permission the call needs — for a read with includeRelatedTypes: "types", instance.get_own; the message names it. See Related types. | No. Repeat without the option, or use a key that holds it. |
For QUOTA_EXCEEDED, details carries kind
("daily_quota_exceeded" | "monthly_quota_exceeded") and
retry_after_seconds (number | null); when the window is resettable the
server also sends a Retry-After header, surfaced as retryAfter (seconds).
RATE_LIMITED is retryable — honour retryAfter (or Retry-After) for backoff.
The client never retries on its own; it only surfaces the value.
try {
await inst.write("...");
} catch (e) {
if (!(e instanceof XmemoryAPIError)) throw e;
switch (e.code) {
case "QUOTA_EXCEEDED": {
// Non-retryable: plan/usage allowance exhausted.
const kind = (e.details as { kind?: string } | null)?.kind; // daily_ | monthly_quota_exceeded
console.error(`Quota exhausted (${kind}); resets in ${e.retryAfter ?? "?"}s`);
break;
}
case "RATE_LIMITED":
// Retryable: back off and retry, honouring Retry-After.
console.error(`Rate limited; retry after ${e.retryAfter ?? "a short delay"}s`);
break;
default:
console.error(`API error (HTTP ${e.status}, code ${e.code}): ${e.message}`);
}
}The schema-evolution endpoints return codes you can pattern match on via .code
the same way — for example stale_proposal_version, dependency_closure_failed,
destructive_confirmation_required, non_additive_change_requires_plan,
stale_schema_version, migration_not_found, instance_not_initialised:
try {
await inst.applyPendingDecisions(token);
} catch (e) {
if (e instanceof XmemoryAPIError && e.code === "stale_proposal_version") {
const review = await inst.reviewSuggestions(); // re-review and retry
}
}All timeouts are per-request
Every method accepts an optional timeoutMs in its options bag, overriding the client default.
const result = await inst.read("query", { timeoutMs: 120_000 });Mastra integration
You can also use xmemory as an MCP server within Mastra.ai.
First, create a local Mastra instance:
npm create mastra@latest mastra-with-xmemoryFrom within this example mastra-with-xmemory directory, first give it some LLM key:
echo "export ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY_FOR_MASTRA" >>.envThen you may want to add MCP support to Mastra first, for its hot reload to pick up xmemory right away.
npm i @mastra/mcpThen fire up the AI-assisted IDE of your choice and give it this prompt.
We need to integrate the `xmemory` MCP server with the Mastra instance running from this directory.
To do this we need to add the `xmemory` Agent, alongside the Weather Agent, and the `xmemory` MCP server to use the Tools from it.
The `xmemory` Agent setup is straightforward, just clone what the Weather Agent has, with `xmemory`-specific instructions. Use the following instructions:
~ ~ ~
> You are the xmemory assistant. You help users manage and query their xmemory instance:
> - Create and configure new instances, generate or enhance schemas, connect and disconnect from instances.
> - Use the xmemory_admin_* tools to perform administrative and schema operations as requested.
> - Be concise and confirm what you did after each action.
~ ~ ~
For the MCP server, you need to add `@mastra/mcp` into `package.json` if it's not already there.
And then make changes along these lines:
new file mode 100644
--- /dev/null
+++ b/src/mastra/mcp-clients.ts
@@ -0,0 +1,19 @@
+import { MCPClient } from '@mastra/mcp';
+
+if (!process.env.XMEM_MCP_BEARER_TOKEN) {
+ throw new Error('XMEM_MCP_BEARER_TOKEN environment variable is required');
+}
+
+export const xmemoryMcp = new MCPClient({
+ id: 'xmemory',
+ servers: {
+ xmemory: {
+ url: new URL('https://dk-mcp.xmemory.ai'),
+ requestInit: {
+ headers: {
+ Authorization: `Bearer ${process.env.XMEM_MCP_BEARER_TOKEN}`,
+ },
+ },
+ },
+ },
+});
new file mode 100644
--- /dev/null
+++ b/src/mastra/xmemory-tools.ts
@@ -0,0 +1,10 @@
+import { xmemoryMcp } from './mcp-clients';
+
+let xmemoryTools: Record<string, any> = {};
+try {
+ xmemoryTools = await xmemoryMcp.listTools();
+} catch (err) {
+ console.error('Failed to load xmemory MCP tools:', err);
+}
+
+export { xmemoryTools };
~ ~ ~
Commit the above as "Added `xmemory` to Mastra."