All guides
Updated
Reading
9 min
By
Anlyon Team

How to see what your AI agent did for one user: query invocations, approvals and receipts

Query what an AI agent did for one user: their calls, graded receipts, pending approvals and connections, with code for an Activity tab and a support script.

  • end users
  • receipts
  • audit log
  • invocations
  • approvals
  • support

Every call your agent makes through anlyon.forUser(userId) is stored against that user. To see what the agent did for one user, list their calls with actions.invocations({ endUserId }), their graded receipts with actions.effects({ endUserId }), their pending approvals with endUsers.approvals.list(userId) and their connections with endUsers.listConnections(userId).

That is enough to build an Activity tab in your app and a lookup script for support, without writing an audit table of your own. This guide covers each query, what the grades mean, and both builds.

Why you need a per-user activity log

A user writes in: "your assistant moved my deal to closed lost." Support needs to answer three questions in under a minute. Did the agent do it? Did it land in HubSpot? Who approved it? If the agent called HubSpot with a token from your own process, the answer is somewhere in your logs, if you logged it. With Anlyon making the call, the record already exists, keyed by the user your server named.

What you can query

QuestionCallScope the key needs
Which users have activity, and how much?endUsers.list({ cursor })end-users:read
What did the agent try to do for this user?actions.invocations({ endUserId, status })actions:read
What actually happened, and how sure are we?actions.effects({ endUserId, grade, unresolved })actions:read
The full story of one receiptactions.effect(effectId)actions:read
What is waiting on this user's yes?endUsers.approvals.list(userId, { status: 'pending' })end-users:approvals
Which accounts has this user connected?endUsers.listConnections(userId)end-users:read

An invocation is one call to a named action. A receipt (an effect) exists for a governed action: a pack write, an action with an adapter, or one with an impact or read-back declaration. A plain template action with no declaration gets an invocation and no receipt.

List your users, with counts

import { Client } from '@anlyonhq/sdk';

const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

let cursor: string | undefined;
do {
  const page = await anlyon.endUsers.list({ cursor, limit: 100 });
  for (const u of page.data) {
    console.log(u.endUserId, u.connections, u.invocations, u.pendingApprovals);
  }
  cursor = page.pagination.nextCursor ?? undefined;
} while (cursor);

The list holds every user with a connection, a call or an approval in this environment, in id order, up to 100 a page. Erased users are not listed. A client from forUser() acts for one user and refuses to list the others.

One user's calls

const { data: calls, pagination } = await anlyon.actions.invocations({
  endUserId: 'user_42',
  limit: 50,
});

for (const inv of calls ?? []) {
  console.log(inv.createdAt, inv.actionName, inv.status, inv.effectId, inv.decision?.explanation);
}
// pagination: { page, limit, total, totalPages, hasMore }; page with offset

Filter by status to find what needs attention: pending_approval, running, succeeded, failed, unknown, denied or expired. Filter by action to see one action's history. Results come newest first.

Pass endUserId explicitly. A key created with "Require an end user" is narrowed to its own user on every list, but an ordinary key lists every user's calls unless you filter.

inv.decision is a frozen record of why the call was allowed or refused: the policy and its version, a person, or expiry, with an explanation that is safe to show. It reads the same after the policy is edited or deleted.

One user's receipts

// Everything that is confirmed
const confirmed = await anlyon.actions.effects({ endUserId: 'user_42', grade: 'confirmed', limit: 50 });

// Everything still open: pending, unknown, or possibly partial
const open = await anlyon.actions.effects({ endUserId: 'user_42', unresolved: true });

for (const e of open.data ?? []) {
  console.log(e.id, e.action.name, e.outcome, e.grade, e.reconciliation.attempts, e.reconciliation.exhausted);
}

Each receipt carries grade, outcome, verification (provider, manual, unverified or not_dispatched), the target, the impact it counted against limits, the approvalId that cleared it, and providerReference, the provider's own id for the write when there is one.

What the grades mean

GradeMeaningWhat to tell the user
confirmedThe call succeeded and a read-back of the provider's own record matchedIt happened, and HubSpot says so
acknowledgedThe provider answered 2xx and nothing read it back. An operator's manual "succeeded" grades here tooThe provider accepted it
unknownNo usable response. Anlyon does not send it againWe are checking whether it happened
failedThe provider rejected it, or it provably never left AnlyonIt did not happen
refusedAnlyon refused it at dispatch, for example an exhausted limitIt did not run
deniedThe approval was deniedIt did not run
pendingNo receipt yetStill in progress

Use those words in your UI. A receipt is a database record with the provider's evidence attached, so call it a receipt, not a signature.

One receipt in full

const { data: effect } = await anlyon.actions.effect('eff_01JABCDEF');

console.log(effect!.grade, effect!.verification, effect!.request);
for (const item of effect!.evidence) console.log(item.observedAt, item.source, item.summary);
for (const a of effect!.attempts) console.log(a.number, a.kind, a.status, a.observedOutcome);

The detail adds the exact request that was sent, every dispatch and reconcile attempt, the limit reservations, the bound preview, and evidence, which is append-only: a later observation never erases an earlier one.

Unknown, and how it settles

unknown is not a failure. A timeout or a dropped connection after the request was written means the deal may have moved. Anlyon keeps the receipt unknown, holds that user's limit allowance, and looks the operation up at the provider, read-only, on a backoff schedule. A match settles it. If the lookups run out, reconciliation.exhausted is true and an operator with actions:resolve settles it from evidence. The full runbook is in reconciling an unknown agent action.

const { data: stuck } = await anlyon.actions.invocations({ endUserId: 'user_42', status: 'unknown' });

Anlyon never retries an action invocation on its own, and your code should not either.

Pending approvals and connections

const { data: approvals } = await anlyon.endUsers.approvals.list('user_42', { status: 'pending' });
for (const a of approvals ?? []) console.log(a.id, a.title, a.expiresAt);

const { data: connections } = await anlyon.endUsers.listConnections('user_42');
for (const c of connections ?? []) console.log(c.integration, c.status, c.scopes);

These are the approvals a policy with decidedBy: 'end_user' created for this user. An approval belonging to another user answers 404. A connection with status: 'needs_reconnect' was refused by the provider or failed to refresh. Calls that need it are stopped before anything is sent and answer with a fresh connect link.

Your accounts or your users'

actingFor splits records by whose account the call used: 'workspace' for your own connected accounts, 'end_users' for any of your users'. It works on invocations, effects and the team approvals list.

const userSide = await anlyon.actions.effects({ actingFor: 'end_users', unresolved: true });
const ourSide = await anlyon.actions.invocations({ actingFor: 'workspace', status: 'failed' });

Build an Activity tab for each user

One server route returns what the agent did for the signed-in user. Invocation lists leave out grade, so the route joins each call to its receipt by invocationId. Both lists are newest first.

// GET /api/activity
export async function GET(request: Request) {
  const user = await requireSession(request); // the user from your session, never from the query
  const [calls, receipts, waiting, connections] = await Promise.all([
    anlyon.actions.invocations({ endUserId: user.id, limit: 50 }),
    anlyon.actions.effects({ endUserId: user.id, limit: 50 }),
    anlyon.endUsers.approvals.list(user.id, { status: 'pending' }),
    anlyon.endUsers.listConnections(user.id),
  ]);

  const gradeOf = new Map((receipts.data ?? []).map((e) => [e.invocationId, e.grade]));

  return Response.json({
    activity: (calls.data ?? []).map((inv) => ({
      id: inv.id,
      action: inv.actionName,
      at: inv.createdAt,
      status: inv.status,
      grade: gradeOf.get(inv.id) ?? null,
      why: inv.decision?.explanation ?? null,
    })),
    waitingOnYou: (waiting.data ?? []).map((a) => ({ id: a.id, title: a.title, expiresAt: a.expiresAt })),
    connections: connections.data ?? [],
  });
}

Show grade with the plain words from the table, and put the approvals at the top with approve and deny buttons that call endUsers.approvals.decide from your server. The prebuilt ApprovalInbox and Connections components in @anlyonhq/react cover those two panels if you would rather not build them. Do not render inv.body to the user: it is the provider's raw response.

A support lookup script

// scripts/lookup-user.ts: npx tsx scripts/lookup-user.ts user_42
import { Client } from '@anlyonhq/sdk';

const anlyon = new Client({ apiKey: process.env.ANLYON_SUPPORT_KEY! });
const userId = process.argv[2];

const [connections, calls, open, approvals] = await Promise.all([
  anlyon.endUsers.listConnections(userId),
  anlyon.actions.invocations({ endUserId: userId, limit: 20 }),
  anlyon.actions.effects({ endUserId: userId, unresolved: true }),
  anlyon.endUsers.approvals.list(userId, { status: 'pending' }),
]);

console.table(connections.data);
console.table((calls.data ?? []).map((i) => ({ at: i.createdAt, action: i.actionName, status: i.status, effect: i.effectId })));
console.table((open.data ?? []).map((e) => ({ id: e.id, action: e.action.name, outcome: e.outcome, grade: e.grade, exhausted: e.reconciliation.exhausted })));
console.table((approvals.data ?? []).map((a) => ({ id: a.id, title: a.title, expires: a.expiresAt })));

Give the support key actions:read and end-users:read. Add end-users:approvals if support should see pending approvals, knowing that the same scope also lets the key decide them. No key receives these scopes by default.

Raw HTTP

The same queries without an SDK. Every route takes Authorization: Bearer <key>.

API=https://api.anlyon.com/api/v2
AUTH="Authorization: Bearer $ANLYON_API_KEY"

curl -H "$AUTH" "$API/end-users?limit=100&cursor=user_41"
curl -H "$AUTH" "$API/actions/invocations?endUserId=user_42&status=unknown&limit=50&offset=0"
curl -H "$AUTH" "$API/actions/effects?endUserId=user_42&grade=confirmed&unresolved=false"
curl -H "$AUTH" "$API/actions/effects/eff_01JABCDEF"
curl -H "$AUTH" "$API/end-users/user_42/approvals?status=pending"
curl -H "$AUTH" "$API/end-users/user_42/connections"

actingFor=workspace or actingFor=end_users works on the invocation and effect lists. The Python SDK takes the same filters as keyword arguments: anlyon.actions.invocations(end_user_id="user_42", status="unknown") and anlyon.actions.effects(end_user_id="user_42", unresolved=True).

Limits

  • Records are per environment. A user's staging and production activity are queried with each environment's key.
  • Calls your code makes to a provider outside Anlyon are not here.
  • A template action with no impact or read-back declaration has an invocation and no receipt, so no grade.
  • Invocation and effect lists page by limit and offset, up to 100 a page. endUsers.list pages by cursor instead.
  • After endUsers.erase, the user's records stay but carry a keyed one-way hash in place of the id, so a query by the old id returns nothing. See deleting a user from your AI agent.

Next step: add the Activity route to your app for one test user, then open the console's Users page to compare. The fields are documented in outcomes, and the connection flow behind it is in OAuth for AI agents.

Frequently asked questions

How do I get an audit log of what an AI agent did for one customer?
Record every action against the customer's id at the point it is made, then filter by that id. With Anlyon, each call made through anlyon.forUser(userId) is stored with that user, and actions.invocations({ endUserId }) and actions.effects({ endUserId }) return that user's calls and graded receipts.
How can customer support see what an AI agent did in a user's account?
Give support a read-only lookup by user id that shows the user's connections, recent calls with their outcome, receipts that are still unresolved, and approvals waiting on the user. The Anlyon console has a Users page with the same view, and the API returns it with four calls.
What does a confirmed receipt mean for an AI agent action?
Confirmed means the call succeeded and a read-back of the provider's own record matched what was asked. Acknowledged means the provider accepted the request and nothing read it back.
What should I do when an AI agent action shows unknown?
Do not run it again. Unknown means no usable response came back, so the write may or may not have happened; Anlyon does not resend it and looks the operation up at the provider instead. If that lookup runs out, an operator settles it from the provider's own record.

Let your agent act for your users, safely.

A few lines of server code, then send a user a connect link. Nothing to host.

$ claude mcp add --transport http anlyon https://api.anlyon.com/mcp