mastra-ai/mastra · error · Error
Thread not found: ${threadId}
Error message
Thread not found: ${threadId} What it means
The messages-listing path that bypasses full session construction verifies ownership by querying the thread and comparing its resourceId. It throws a plain Error (surfaced as 500 by handleError) when the thread is missing OR exists but belongs to a different resource — an anti-IDOR guard so callers cannot read other resources' messages by guessing ids.
Source
Thrown at packages/server/src/server/handlers/agent-controller.ts:1217
description: 'Lists messages for a specific thread. Returns most recent messages first.',
tags: ['AgentController', 'Threads'],
requiresAuth: true,
requiresPermission: 'agent-controller:read',
handler: async ({ mastra, controllerId, resourceId, threadId, limit }) => {
try {
const controller = getAgentControllerOrThrow(mastra, controllerId);
// Read-only route: query storage directly instead of constructing a
// Session. Session creation would trigger workspace/sandbox
// initialization as a side effect; reads should never pay that cost.
// The query methods lazily initialize storage (not workspace) on their own.
// The route is authorized for the URL's resourceId, but `threadId` is
// otherwise unscoped. Verify the thread belongs to this resource so a
// caller can't peek at another resource's messages by guessing an id
// — matches the check `session.thread.listMessages` performed via
// `session.thread.set` before we bypassed session construction.
const thread = await controller.queryThreadById({ threadId });
if (!thread || thread.resourceId !== resourceId) {
throw new Error(`Thread not found: ${threadId}`);
}
const messages = await controller.queryThreadMessages({ threadId, limit });
return {
messages: messages.map(m => ({
id: m.id,
role: m.role,
content: m.content as { format: 2; parts: Array<{ type: string; [key: string]: unknown }> },
createdAt: m.createdAt instanceof Date ? m.createdAt.toISOString() : undefined,
threadId: m.threadId,
resourceId: m.resourceId,
type: m.type,
})),
};
} catch (error) {
return handleError(error, 'error listing controller thread messages');
}
},
});View on GitHub (pinned to 75dd419e61)
Solutions
- Send the resourceId that actually owns the thread — verify ownership mapping in your client.
- Confirm the threadId is valid in the current storage backend before calling.
- If the thread was deleted/migrated, recreate it or point the client at the new id.
- Check that resourceId normalization (case, prefixes) matches what was used at thread creation.
Example fix
// before
const msgs = await api.listMessages({ resourceId: 'user-a', threadId: threadOwnedByUserB });
// after
const msgs = await api.listMessages({ resourceId: 'user-b', threadId: threadOwnedByUserB }); Defensive patterns
Strategy: validation
Validate before calling
const thread = await api.getThread({ threadId });
if (!thread || thread.resourceId !== resourceId) {
throw new Error('Refusing to list messages: thread missing or not owned by this resource');
} Type guard
function isOwnedThread(t: { resourceId: string } | null | undefined, resourceId: string): t is { resourceId: string } {
return !!t && t.resourceId === resourceId;
} Try / catch
try {
return await api.listMessages({ resourceId, threadId });
} catch (e) {
if (e instanceof Error && e.message.startsWith('Thread not found')) {
return { messages: [] }; // treat as empty/no-access rather than crashing
}
throw e;
} Prevention
- Keep a tenant/resource → thread-id mapping in your app so ids never cross resources.
- Check ownership client-side before calls in multi-tenant UIs.
- Log and alert on mismatches — they usually indicate an IDOR attempt or bad keying.
When it happens
Trigger: GET messages for a threadId that doesn't exist, or that exists under a different resourceId than the one authenticated/supplied; reusing a thread id copied from another user/project; storage migration losing the thread row.
Common situations: Multi-tenant apps passing one tenant's threadId with another's resourceId; shared database across deployments with divergent resourceIds; stale ids after wiping storage.
Related errors
- Factory session does not belong to this project
- Thread not found: ${threadId}
- Thread with id ${threadId} is for resource with id ${thread.
- Google user is not in an allowed hosted domain
- OAuth failed: ${tokenData.error}
AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30).
Data as JSON: /api/errors/cb9995c1d11d74ab.
Report an issue: GitHub.