Skip to content

Documentation

Connect what you already use.

Seven connectors, each written against what its source actually is rather than flattened into documents. A Slack thread keeps its replies, a meeting keeps its offsets, and a Drive file keeps the permissions it was found under.

What each one reads

Email

email

Messages from a mailbox, provider-agnostic: Gmail, Microsoft 365 and IMAP all go through one adapter. Subject, sender, recipients, the plain-text body, and labels. Trailing quoted history is split off where the adapter can find it, so a reply is not re-ingested as the whole thread.

Attachments become their own child inputs, so a PDF sent by email is parsed exactly as an uploaded one would be.

authenticatelistfetchsyncresumethreadsattachments

Google Drive

google_drive

Files, by change feed rather than by rescanning. Google-native documents have no bytes to download, so Docs are exported to PDF, Sheets to xlsx, Slides to pptx, Drawings to png, and Apps Script to json, then handled by the plugin that already knows those formats.

Drive permissions become an access scope, read at its most restrictive: a private file wrongly marked shared cannot be un-leaked. Folders are listed and never fetched, and trashed files are skipped.

authenticatelistfetchsyncresumewatchpermissions

Google Meet

google_meet

Conferences as a single object: title, start and end, the participant list, and the transcript with per-entry offsets, so a decision made at 01:42 stays citable.

A meeting that was never transcribed comes back empty, which is a normal case and not an error. The recording is referenced rather than downloaded here; it is ingested as its own document.

authenticatelistfetchsyncresume

Slack

slack

One input per thread, parent plus replies, in public channels, private channels and DMs the token can see. User records are resolved so a message reads as a name rather than an id.

Threads are never flattened. A reply read without its parent is frequently meaningless. Files posted in a thread are on by default, because the PDF someone posted is usually what the conversation is about.

authenticatelistfetchsyncresumewatchthreadsattachments

Microsoft Teams

microsoft_teams

Three shapes, kept separate: channel conversations with their real reply trees, flat private chats, and meetings carrying attendance plus a transcript. Messages arrive through Graph's per-conversation delta feed.

Collapsing all three into messages would lose the reply edges that make a channel thread readable, so each is emitted as its own structured input.

authenticatelistfetchsyncresumethreadsattachments

WhatsApp

whatsapp

Messages to and from a WhatsApp Business number, delivered by webhook, plus media fetched by id. Text, images, audio, video, documents and stickers, with the quoted-message link preserved.

There is no API that reads a person's existing personal WhatsApp conversations, so this connector is webhook-first. Its sync path only replays what the platform still retains, which is a short window and a catch-up rather than a backfill.

authenticatelistfetchsyncwatchattachments

Telegram

telegram

Anything sent or forwarded to the bot: text, photos, documents, voice notes, video and captions, with forwarded-message provenance and the reply thread preserved. Media is fetched by id and goes through the same OCR and transcription path an upload does.

A bot cannot read history — there is no chat listing and no fetch-by-id — so this is webhook-only and there is no backfill. Group chats are stored as shared and scoped to that chat, never to the whole account. Telegram's privacy mode is ON by default, which means the bot sees only commands and replies to itself in a group until you disable it in BotFather.

authenticatewatchattachments

Any MCP server

mcp

Resources exposed by any MCP server you point it at, listed and read by uri. Text comes through as text, and a resource returning bytes is handed to the same plugin an upload would be.

Resources, not tools, by default. Reading a resource is a read; invoking a tool can have arbitrary effects, so tools are opt-in one by one and never discovered and run. Everything from an MCP server is filed private, because a server's reach is unknown to us.

connectlistfetch

No endpoint here will ever take a password.

Connecting returns a URL to send the person to. It does not accept a token or a password for a third party, and it never will: an API that did would train people to hand those over, which is the habit every phishing attack relies on.

Nothing comes back either. The stored record has no token field at all, not an empty one and not a redacted one, so a listing cannot leak a credential by forgetting to strip it. The state is 32 random bytes and lives for ten minutes; returnTo is checked against an allow-list, because an open redirect turns our domain into a credible link to anywhere.

# 1. Ask what can be connected. Do not hardcode this.
GET /api/v1/integrations/available
{ "providers": [{ "provider": "slack", "reads": "..." }] }

# 2. Start the flow.
POST /api/v1/integrations/connect
{ "provider": "slack", "returnTo": "https://app.example.com/settings",
  "spaceIds": ["sp_work"] }

201
{ "authorizeUrl": "https://slack.com/oauth/v2/authorize?...",
  "state": "0f3c...",
  "expiresAt": "2026-08-29T10:12:00.000Z" }

# 3. Send the browser to authorizeUrl. The callback lands back on us,
#    the state is consumed, and the connection appears in the listing.

The integration API

GET /api/v1/integrations

Your connections. Filter by provider or status, paged by cursor.

GET /api/v1/integrations/available

Providers that can be connected at all, each with what it will read. Ask rather than hardcoding a list, or you will offer one that has no adapter behind it.

POST /api/v1/integrations/connect

Takes a provider, an optional returnTo and optional spaceIds. Answers 201 with authorizeUrl, state and expiresAt.

GET /api/v1/integrations/{id}

One connection, without any credential on it.

PATCH /api/v1/integrations/{id}

Change enabled, or the spaceIds this connection files into. At least one field is required.

POST /api/v1/integrations/{id}/sync

Answers 202 queued. full defaults to false, and is opt-in because on a large Drive it is thousands of documents and a real bill.

DELETE /api/v1/integrations/{id}

Destroys the credentials. The row stays, so the memories this connection produced keep their provenance.

import { PersistMemory } from "@persistmemory/sdk";

const pm = new PersistMemory({ apiKey: process.env.PERSISTMEMORY_API_KEY! });

// File everything this connection finds into one Space.
await pm.integrations.update("int_7f2a", { spaceIds: ["sp_work"] });

// Catch up now rather than waiting for the schedule.
await pm.integrations.sync("int_7f2a");

// Re-read everything. Opt-in, because it is a real bill.
await pm.integrations.sync("int_7f2a", { full: true });

The five states a connection can be in

Worth branching on. Two of them mean nothing is being read, and only one of those is something you can fix from code.

connected

Working. Syncs run on the schedule.

syncing

A run is in flight right now.

reauth_required

The credentials were rejected. Only the user can fix this, so a sync request is refused with 403 rather than retried.

error

The last run failed. The message is sanitised and reads as an instruction.

disabled

Turned off through PATCH. Nothing is read until it is enabled again.

A provider is connectable when its OAuth client is configured, and not otherwise, so the list a deployment offers may be shorter than the seven above. WhatsApp is never in it: it arrives by webhook rather than by consent, so there is no page to send you to. Ask GET /integrations/available rather than assuming. connect refuses a provider it cannot serve and names the ones it can, instead of issuing a URL to nowhere. An integration that appears to connect and then never syncs is worse than one that says plainly it is unavailable: you wait for data instead of knowing to do something else.

Or skip the sources and let an assistant write directly.