- 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
| Question | Call | Scope 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 receipt | actions.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
| Grade | Meaning | What to tell the user |
|---|---|---|
confirmed | The call succeeded and a read-back of the provider's own record matched | It happened, and HubSpot says so |
acknowledged | The provider answered 2xx and nothing read it back. An operator's manual "succeeded" grades here too | The provider accepted it |
unknown | No usable response. Anlyon does not send it again | We are checking whether it happened |
failed | The provider rejected it, or it provably never left Anlyon | It did not happen |
refused | Anlyon refused it at dispatch, for example an exhausted limit | It did not run |
denied | The approval was denied | It did not run |
pending | No receipt yet | Still 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
limitandoffset, up to 100 a page.endUsers.listpages 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.
