MMRY AI Public API

Memory infrastructure for AI assistants. Fourteen operations, OAuth 2.0, designed for ChatGPT, Copilot, Gemini, Cursor, and MCP-compatible platforms.

API version 1.0  |  Last updated 2026-07-15
On this page

Overview

The MMRY AI Public API lets external AI platforms maintain persistent memory for their users across sessions. A user grants your platform access through a standard OAuth 2.0 consent screen on mmryai.com. After that, your platform can load context at the start of a conversation, search memories during a conversation, save new memories worth remembering, set or change who can see a memory, correct a structured record the user keeps, and read what that record used to hold, all on the user's behalf, scoped to their account.

The surface is intentionally narrow:

The machine-readable spec lives at https://mmryai.com/openapi/v1.json. Like the rest of these docs, the spec is gated to authenticated MMRY AI users, fetch it with your JWT or API key. Use the download button below to save it as mmry-openapi-v1.json, for use with OpenAPI-aware tooling. Connecting an AI assistant such as ChatGPT? You do not need the spec at all: use the MCP connector below.

Connect an AI tool (MCP)

The easiest way to use MMRY AI is as a Model Context Protocol (MCP) connector. Most AI assistants can connect to a remote MCP server by URL. MMRY AI lives at:

https://mmryai.com/mcp

Add that as a custom connector in your AI tool. It registers itself automatically and walks you through signing in to your MMRY AI account. There are no client IDs, secrets, or settings to manage. Once connected, your assistant can load, search, and save memories on your behalf, choose whether each one is private, shared with a memory group, or open to your organization, and change that later. The same memories are available in every tool you connect.

ChatGPT

Open Settings, then Connectors (turn on Developer mode first if prompted), then add a custom connector. Enter https://mmryai.com/mcp and authorize.

Claude (Desktop or Team)

Open Settings, then Connectors, then add a custom connector. Enter https://mmryai.com/mcp and authorize. Team owners can add it once for the whole workspace; each member still signs in to their own account, and memories stay private to each person.

Cursor, Copilot, and other MCP clients

Any MCP-capable client that accepts a remote server URL can connect. Point it at https://mmryai.com/mcp.

What works over MCP

Loading, searching, saving, correcting a record, reading what a record used to hold, and setting who can see a memory all work in every connected tool, and the same memories are there in all of them. Nothing about structured records is read only anywhere: a record you write in Claude Code can be corrected from ChatGPT, from Cursor, from your account area or over the REST API, a record you write in any of those can be corrected from Claude Code, and every one of those surfaces, Claude Code included, can show you what a value used to be. Formations, where several assistants coordinate on one job, work in every connected tool as well: an assistant on the connector can list your Formations, join one, and tell the Formation what it is doing. It can also close the Formation out, but only when you are that Formation's lead, set its objective, or administer the account. It cannot start one: a Formation is started from Claude Code or Codex, so an account working only through the connector needs someone in Claude Code to start one and pass on its id. Delivery differs by tool. In Claude Code a message is pushed to a session as it works; through the connector it is pulled, reaching the assistant the next time it uses any MMRY tool rather than the moment it is sent. In Claude Code, reaching a member that is sitting idle also needs Claude Code 2.1.64 or newer; on an older version the message is held and arrives the next time that session does anything.

What every MCP tool response carries

A tool response is a list of text blocks, in this order: the tool's own result as JSON; the capability index; any Formation messages waiting for this connection; and the account's current Foundation memories. The Formation and Foundation blocks appear only when there is something to carry. The capability index names every capability MMRY offers and its tools, so an assistant whose connection opened before a capability was added still learns that it exists:

MMRY capabilities (briefing r4), current as of this call. Through MMRY you can: (1) memories (mmry_load, mmry_search, mmry_save, mmry_set_visibility); (2) structured records with named fields, including creating record types (mmry_format_*, mmry_record*); (3) coordination groups with the user's other AI assistants (mmry_formation_*). If a tool named here is missing from your tool list, your connection to MMRY opened before that tool existed: tell the user the capability exists and that starting a new conversation, or reconnecting MMRY in their app, makes it available. Never tell the user MMRY cannot do one of these. mmry_load returns the full briefing.

mmry_load puts the full capability briefing first in place of the index. It begins MMRY capability briefing r4, current as of this call. and ends End of MMRY briefing r4., and it contains the same briefing the server sends as its instructions when a connection opens.

Joining a Formation. The result of mmry_formation_join includes memberRules, the two standing rules every member of a Formation follows. They are the same rules a member joining from Claude Code or Codex is given. Abbreviated:

{
  "joined": true,
  "formation": { ... },
  "members": [ ... ],
  "memberRules": [
    "Report to the lead at every stopping point, with a message directed to the lead: when you finish, when you are blocked, when you are waiting, and when you stop with work still running, saying what is still running. Never hold finished work back for a slow check; report it and say the check is still running.",
    "When you receive an assignment, a brief or a question, acknowledge it before you start, with substance: what you understood, anything you cannot do, and when to expect the result. If the job is short, fold that into your first update. Findings and status updates need no acknowledgement."
  ],
  "delivery": "Delivery is pull, not push: ..."
}

The rest of this page documents the underlying OAuth 2.0 and REST API, for platforms that integrate MMRY AI directly rather than through the MCP connector.

Authentication, OAuth 2.0

Every request to /v1/* requires an OAuth 2.0 bearer access token. The authorization code flow is standard:

User clicks "Connect MMRY AI" │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Your platform redirects to │ │ https://mmryai.com/oauth/authorize? │ │ client_id=...&redirect_uri=...&response_type=code │ │ [&state=...] [&code_challenge=...&code_challenge_method=S256] └───────────────────────────────────────────────────────────────┘ │ ▼ User logs in (or creates account) and clicks Authorize │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ MMRY AI redirects browser back to your redirect_uri │ │ ?code=<short-lived auth code>[&state=...] │ └───────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Your server exchanges the code for tokens (form-encoded) │ │ POST https://mmryai.com/oauth/token │ │ grant_type=authorization_code & code & redirect_uri │ │ & client_id & client_secret [& code_verifier] │ └───────────────────────────────────────────────────────────────┘ │ ▼ Response: { access_token, token_type: "bearer", expires_in, refresh_token } │ ▼ Your platform calls /v1/* with: Authorization: Bearer <access_token>

Authorization request

Redirect the user's browser to:

GET https://mmryai.com/oauth/authorize
    ?client_id=YOUR_CLIENT_ID
    &redirect_uri=https://your-platform.example.com/callback
    &response_type=code
    &state=OPAQUE_STATE_FOR_CSRF
    &code_challenge=SHA256_OF_VERIFIER_BASE64URL
    &code_challenge_method=S256

The redirect_uri must match one of the redirect URIs registered for your client. PKCE is optional but recommended; we accept S256 or plain.

Token exchange

POST https://mmryai.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=THE_CODE_FROM_REDIRECT
&redirect_uri=https://your-platform.example.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=YOUR_PKCE_VERIFIER

Response (200):

{
  "access_token": "<64-char hex>",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "<64-char hex>"
}

Access tokens are valid for 1 hour. Refresh tokens are valid for 30 days, single-use, and rotated on every refresh.

Refresh

POST https://mmryai.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=YOUR_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
Refresh rotation matters. Every refresh returns a new refresh_token and invalidates the old one. If you ever see a refresh fail with invalid_grant, treat the user as if they have logged out and restart the authorization flow from the beginning. Do not retry the same refresh_token.

Revoke

POST https://mmryai.com/oauth/revoke
Content-Type: application/json

{ "token": "<access_token or refresh_token>" }

Revoking a refresh token cascades to every access token derived from it.

Discovery

The OIDC discovery document is at https://mmryai.com/.well-known/openid-configuration and lists the canonical endpoint URLs above.

The six memory operations

These six, and the eight record-type operations below them, live under /v1/ and require the Authorization: Bearer <access_token> header.

Load memories

GET/v1/memories

Returns the memories visible to the authenticated user, newest first, in pages of 100. Pass page to walk through additional pages, and optionally filter by scope:

GET /v1/memories?scope=payments&page=1 HTTP/1.1
Authorization: Bearer <access_token>

Query parameters:

Response (200):

{
  "items": [
    {
      "id": 42,
      "topic": "Refund Provider",
      "content": "Use Stripe for all refund processing.",
      "scope": "payments",
      "category": "Decision",
      "createdAt": "2026-02-22T10:00:00Z"
    }
  ],
  "total": 342,
  "page": 1,
  "pageSize": 100,
  "hasMore": true
}

To retrieve everything, request page=1, then increment page until hasMore is false. total is the full count across all pages.

Search memories

GET/v1/memories/search?q=<keyword>

Keyword search across topic + content. The q parameter is required (1-200 chars). Optional scope filter.

GET /v1/memories/search?q=refund HTTP/1.1
Authorization: Bearer <access_token>

Save a memory

POST/v1/memories
POST /v1/memories HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "topic": "Refund Provider",
  "content": "Use Stripe for all refund processing.",
  "scope": "payments",
  "visibility": "organization"
}

Response (201):

{
  "id": 42,
  "topic": "Refund Provider",
  "content": "Use Stripe for all refund processing.",
  "scope": "payments",
  "category": "Decision",
  "createdAt": "2026-02-22T10:00:00Z",
  "visibility": "Global",
  "group": null
}

Fields:

Category is determined by MMRY AI, your platform never has to think about it.

The response always states the visibility that was applied, so you can tell the user what actually happened rather than assuming.

Which agent saved a memory

Every memory carries agentName, the name of the agent that saved it, so a team running several named agents can see which one contributed what. On /v1 it is part of every memory returned by GET /v1/memories, GET /v1/memories/search, POST /v1/memories and PUT /v1/memories/{id}/visibility. It is always present, and null when the save named no agent. POST /v1/memories does not take a name, so a memory your platform saves through /v1 carries null; names come from saves made by the MMRY plugin and through the MCP connector.

Over MCP, mmry_save takes an optional agentName argument, and the memories returned by mmry_save, mmry_load and mmry_search carry it:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "mmry_save",
    "arguments": {
      "topic": "Deploy window",
      "content": "The deploy window is Friday.",
      "agentName": "release-manager"
    }
  }
}

The first text block of the result:

{
  "id": 42,
  "topic": "Deploy window",
  "content": "The deploy window is Friday.",
  "scope": "general",
  "category": "Fact",
  "createdAt": "2026-10-12T10:00:00Z",
  "visibility": "Global",
  "group": null,
  "record": null,
  "agentName": "release-manager"
}

On the MMRY plugin's routes, POST /api/memories and POST /api/memories/process accept "agentName" in the request body, and GET /api/memories, GET /api/memories/search, GET /api/memories/{id}, the POST /api/memories response and every memory in the GET /api/memories/export file carry it, null when there is none.

A name is trimmed, a blank name is stored as none, and a name can be up to 100 characters. A longer one is refused rather than shortened, and nothing is saved: the /api routes answer 400 with the validation problem document keyed by AgentName, and mmry_save returns a tool error.

Set who can see a memory

PUT/v1/memories/{id}/visibility

Changes who can see a memory the user already saved. Use it when they ask to make something private, share it with one of their memory groups, or open it to their organization.

PUT /v1/memories/42/visibility HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "visibility": "private"
}

Response (200):

{
  "id": 42,
  "topic": "Refund Provider",
  "content": "Use Stripe for all refund processing.",
  "scope": "payments",
  "category": "Decision",
  "createdAt": "2026-02-22T10:00:00Z",
  "visibility": "Private",
  "group": null
}

Only the person who saved a memory can change who sees it. This fails for anyone else, including account administrators. That is deliberate, not a limitation.

Correct a record

PUT/v1/memories/{id}/record

Corrects a structured record: its fields, its words, its topic, or its category. A structured record is a memory the user saved against one of their own record types, so it holds named values rather than only prose. Every change made here is recorded, so the user can see what each value used to be and who changed it.

Send only what is changing. A field you omit is left exactly as it was, including who can see the record.

PUT /v1/memories/42/record HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "fields": { "status": "Shipped" }
}

Ordinary memories are not corrected here, and structured records are not corrected on the ordinary save route. Each route refuses the other's memory by name and tells you which one to use.

What a record used to hold

GET/v1/memories/{id}/record/history

Returns the record's changes, oldest first: what each value was, what it became, when, and who made the change. Use it to answer "what did this say before?" without the user having to remember.

GET /v1/memories/42/record/history HTTP/1.1
Authorization: Bearer <access_token>

A user sees the history only of a record they are entitled to read, on exactly the same terms as the record itself.

The record types a person keeps

Eight further operations live under /v1/types, on the same bearer token. A record type is a structure a person has asked their assistants to keep for them, a migraine log, a set of tracked tasks, and a record is one entry in it. These routes let your platform review what a person keeps, add to it, and manage the types themselves. They are listed here because they are part of the published surface; the request and response shapes are in the OpenAPI spec above.

GET/v1/types

The record types this person keeps.

GET/v1/types/{id}

One type, with the fields it declares.

GET/v1/types/{id}/records

The records of one type, paged, and filterable by field value.

POST/v1/types

Design a new type on the person's behalf.

POST/v1/types/{id}/records

Write a record into a type.

PUT/v1/types/{id}

Change a type's name, description or matching hints.

Match hints are the words a person would use about an example of the type. An ordinary save is filed as a record of the type when its words contain them and it has values for the type's fields, supplied with the save or written as labelled lines such as Amount: 25; otherwise it stays an ordinary memory. To be sure something is recorded against a type, write it with POST /v1/types/{id}/records.

POST/v1/types/{id}/retire

Stop a type collecting. Everything it holds is kept, and the answer says how much that is.

POST/v1/types/{id}/reinstate

The mirror of retire, because nothing was destroyed.

There is no delete route and there will not be one. A type is superseded, never detached and never removed, including an empty one.

Formation messages and read status

A formation is a group of assistant sessions coordinating on one job. The routes below are how a member session collects its messages and how a sender sees whether a message it sent to one member has been read. They are the routes the MMRY plugin for Claude Code and Codex uses, and they sit outside the /v1/ surface: they live under /api/formations and take an MMRY API key in the X-Api-Key header or an MMRY sign-in token as Authorization: Bearer <token>. A connected assistant gets the same information through the MCP connector, where mmry_formation_status lists the directed messages that connection sent and whether each has been read.

Membership is held per session, so every call names the caller's own sessionId (required, at most 50 characters, no line breaks or control characters). Times are UTC.

Collect messages, and report what was shown

GET/api/formations/{id}/transmissions

Returns the messages this session may hear since the position it last heard something at, oldest first. Pass the last item's sentDate and transmissionID as since and sinceId on the next call.

GET /api/formations/4959/transmissions?sessionId=b7e2c1d4&since=2026-10-07T14:01:40.117&sinceId=8801&shownIds=8802,8803 HTTP/1.1
X-Api-Key: <api_key>

Query parameters:

Response (200):

[
  {
    "transmissionID": 8804,
    "formationID": 4959,
    "content": "Rewrite the validator so it accepts the new date format.",
    "senderSessionID": null,
    "senderUserID": 1201,
    "senderRole": "Lead",
    "recipientMemberID": 12,
    "sentDate": "2026-10-07T14:02:01.640"
  }
]

recipientMemberID is the member a message was sent to, or null for a message to the whole formation. A session that is not a current member receives an empty list, the same answer as a formation with nothing new, so the response never reveals whether another team's formation exists.

See whether your directed messages have been read

GET/api/formations/{id}/transmissions/sent

Lists the messages this session sent to one member in this formation, newest first, with whether each has been shown to its recipient. It also answers whether this session is still a member, which is what the plugin checks before it renews its idle watch.

GET /api/formations/4959/transmissions/sent?sessionId=a91f03e2&top=20 HTTP/1.1
X-Api-Key: <api_key>

Query parameters:

Response (200):

{
  "formationId": 4959,
  "member": true,
  "messages": [
    {
      "transmissionId": 8805,
      "recipientMemberId": 14,
      "recipientRole": "Wingman",
      "recipientHasLeft": false,
      "sentDate": "2026-10-07T14:05:12.902",
      "read": false,
      "readDate": null,
      "preview": "Take the import bug next."
    },
    {
      "transmissionId": 8804,
      "recipientMemberId": 12,
      "recipientRole": "Wingman",
      "recipientHasLeft": false,
      "sentDate": "2026-10-07T14:02:01.640",
      "read": true,
      "readDate": "2026-10-07T14:02:03.417",
      "preview": "Rewrite the validator so it accepts the new date format."
    }
  ]
}

Fields of each message:

Only messages sent to one member are listed. A message to the whole formation has no single reader, so it has no read state and does not appear here.

Not a member. When this session is not a current member of an active formation with this id, whether the formation does not exist, has been closed out, belongs to someone else, or this session has left it, the answer is still 200, with member set to false and no messages:

{ "formationId": 4959, "member": false, "messages": [] }

Errors on the formation routes

Errors & rate limiting

Error response shape

4xx and 5xx responses return either an OAuth-style error or an RFC 7807 problem document, as JSON with a non-empty body. The one exception is 413, a request body that is too large, which has an empty body (see below).

{
  "error": "invalid_grant",
  "error_description": "Refresh token expired."
}

or for validation failures, where each key in errors is the field name as the server declares it (Content, not content) and every answer also carries a per-request traceId:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": { "Content": ["Content is required."] },
  "traceId": "00-5b91226d3f9f3d668c0dca4feb591c5a-44b2bfdaa8586aae-00"
}

Request too large (413)

Every request body is limited to 64 KB (65,536 bytes). A larger one is answered 413 Payload Too Large with an empty body, not JSON, and nothing in it is saved.

HTTP/1.1 413 Payload Too Large
Content-Length: 0

The limit counts bytes, not characters. Bodies are UTF-8, so text outside plain ASCII takes two to four bytes a character and reaches the limit well below 64,000 characters. It applies to the whole body, including the other fields and the JSON around them. A memory's content has no character limit of its own, so this is the only ceiling on POST /v1/memories. Retrying the same body will not help: shorten it, or split it across more than one memory.

Authentication failures (401)

A /v1/* request returns 401 when the bearer token is missing, expired, or no longer valid. A token can stop working before its normal expiry if it was revoked or if MMRY AI deactivates your platform's OAuth client; in that case the token is rejected immediately. Treat any 401 as a signal to obtain a fresh token through the authorization flow (or refresh), and if that also fails, re-connect your platform.

Rate limits

Per-platform requests to /v1/* are limited to 200 requests per 60-second window by default. Specific platforms may have higher limits configured by MMRY AI.

When you exceed the limit you receive HTTP 429 with:

{ "error": "Too many requests", "retryAfterSeconds": 60 }

When the limiter can report how much of the window is left, the Retry-After response header carries the same value. Always respect it; back off and retry after the window elapses. If the limiter cannot report it, retryAfterSeconds is null and no header is sent, back off a short interval of your own in that case.

A 429 is about your quota. It is not the 503 below, which is about our capacity. Both carry Retry-After and both are worth retrying, but they have different causes and are worth handling separately.

Service at capacity (503)

When MMRY AI is genuinely at capacity, a /v1/* request is answered 503 Service Unavailable with a Retry-After header, rather than the 500 it would have returned before v1.24. A 503 means your request was fine and the service was momentarily full.

HTTP/1.1 503 Service Unavailable
Retry-After: 2

{ "error": "The service is briefly at capacity. Please retry in a moment." }

Wait the number of seconds in Retry-After, then send the request again. That value is between one and three seconds and is varied deliberately from one caller to the next. The requests refused together are the ones that arrived together, so releasing them all at the same instant would simply rebuild the burst. Honour the value we give you rather than substituting a fixed delay of your own, the variation is what separates you from everybody else being retried at that moment.

It is not confined to one route. Any /v1/* route can answer 503 this way. 503 itself is not new to this API, some endpoints have always answered 503 when a dependency is unavailable or unconfigured, but those do not set Retry-After, because retrying does not resolve them. Treat the presence of Retry-After as the signal that a 503 is worth repeating.

One caveat worth stating rather than leaving you to find. A request's credit is taken before the request is served and is not returned when it fails, so a retry after a 503 costs another credit. That is the cost to be aware of before you put a 503 retry in a loop.

If your client treats every 5xx as permanent, it will now give up on something it should retry. That is the change most likely to affect an integration that already works.

Over the MCP connector this looks different. A capacity failure inside an MCP tool call is returned as HTTP 200 with "isError": true in the JSON-RPC body, because that is how the MCP protocol reports a tool failure, not as a 503. A 503 reaches an MCP client only when the failure happens before the tool runs, in authentication or accounting. If you integrate over /mcp rather than /v1/*, handle the error result, not the status code.

In a healthy deployment a 503 should not appear at all. It is a safety net, not an expected answer.

Registering a platform

MMRY supports RFC 7591 Dynamic Client Registration, so most platforms need no manual setup. MCP clients such as Claude, ChatGPT, and Cursor register themselves automatically the first time a user connects. The user simply adds the connector URL https://mmryai.com/mcp and approves the consent screen.

To register a client directly, POST to the registration endpoint. No authentication is required, and the endpoint is rate-limited:

POST https://mmryai.com/oauth/register
Content-Type: application/json

{
  "client_name": "Your Platform",
  "redirect_uris": ["https://yourplatform.example/oauth/callback"]
}

By default the client is registered as a public client (PKCE, token_endpoint_auth_method: "none") and no client secret is issued. To register a confidential client, set "token_endpoint_auth_method": "client_secret_post"; the response then includes a client_secret that is returned exactly once. A successful call returns 201 Created:

{
  "client_id": "mcp_a1b2c3...",
  "client_id_issued_at": 1752566400,
  "token_endpoint_auth_method": "none",
  "redirect_uris": ["https://yourplatform.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}

Endpoint URLs are discoverable without registration. https://mmryai.com/.well-known/oauth-authorization-server (RFC 8414) advertises the authorization, token, and registration endpoints and the supported token endpoint auth methods client_secret_post and none. https://mmryai.com/.well-known/oauth-protected-resource (RFC 9728) advertises the protected resource and its authorization server. The OIDC document at /.well-known/openid-configuration remains available.

Connecting ChatGPT, Claude, or Cursor? You do not need to register anything by hand. Use the built-in MCP connector and point it at https://mmryai.com/mcp; client registration and the OAuth flow happen automatically. See Connect an AI tool (MCP) above.

FAQ

Do my users need a MMRY AI account?

Yes. The OAuth consent screen is the place where a new user can create an account inline; the flow lands them back at your platform with a working connection. If they cancel the consent screen, your platform receives error=access_denied at the redirect URI.

What memories can my platform see?

Only memories belonging to the authenticated user. Multi-tenant isolation is enforced by MMRY AI at every layer; cross-account leakage is impossible.

Can my platform classify memories?

No. Classification (category, scope semantics, retention) is handled internally and stays stable across MMRY AI releases. The intent is that your platform never has to manage memory lifecycle, just save what's worth remembering and read what's relevant.

What happens if a user revokes my platform's access?

The user can revoke at https://mmryai.com/account. Your existing access and refresh tokens are immediately invalidated. The next request returns 401; your platform should handle this by prompting the user to reconnect.

How do I rotate my client secret?

Public (PKCE) clients have no secret, so there is nothing to rotate. They are safe by design. If you registered a confidential client and need to roll its secret, register a fresh client via POST /oauth/register and cut over to it, or email platforms@mmryai.com for help. When a confidential client's secret changes, that client's existing tokens stop working, so plan for a brief reconnection window.

Where can I see what my platform is doing?

Each call to /v1/* is logged on the MMRY AI side with platform attribution, the user, the operation, and timestamp. Per-platform usage reports are available on request.