paperclipai/paperclip · error

paperclip_runner_chat_attachment_destination_denied

paperclip_runner_chat_attachment_destination_denied

Error message

paperclip_runner_chat_attachment_destination_denied

What it means

Thrown by authorizeChatConversationForBoundRun when destinationAllowed(endpoint, conversation, resource) returns false. After resolving the optional chatEndpointResources row for the conversation, this final policy check rejects delivery of bound output to the requested destination — e.g. the conversation type, resource scope, or endpoint destination policy does not allow replies for this binding. Distinct from the _binding_denied family: binding checks passed but the destination itself is disallowed.

Solutions

  1. Review the endpoint's destination policy settings and enable the conversation type/resource as an allowed reply destination.
  2. Verify chatEndpointResources row for conversation.resourceId exists with matching companyId and endpointId.
  3. Re-point or recreate the conversation if its resource was deleted or reassigned to another endpoint.
  4. Fall back to a permitted destination (e.g. the originating thread) instead of the disallowed one.

Example fix

// before: assumes any conversation on the endpoint is a valid destination
await authorizeChatConversationForBoundRun(tx, binding, ctx);
// after: check destination policy first
const allowed = await isDestinationAllowed(endpoint, conversation);
if (!allowed) {
  console.warn("conversation not an allowed reply destination; falling back to origin thread");
  await deliverToOriginThread(binding);
  return;
}
await authorizeChatConversationForBoundRun(tx, binding, ctx);
Defensive patterns

Strategy: try-catch

Validate before calling

// Verify the conversation resource is valid for this endpoint before authorizing
if (conversation.resourceId) {
  const [resource] = await db.select()
    .from(chatEndpointResources)
    .where(and(
      eq(chatEndpointResources.id, conversation.resourceId),
      eq(chatEndpointResources.companyId, binding.companyId),
      eq(chatEndpointResources.endpointId, endpointId),
    ));
  if (!resource) throw new Error("conversation resource missing or belongs to another endpoint");
}

Type guard

function resourceBelongsToEndpoint(resource: { endpointId: string } | null, endpointId: string): resource is { endpointId: string } {
  return resource !== null && resource.endpointId === endpointId;
}

Try / catch

try {
  return await authorizeChatConversationForBoundRun(tx, binding, ctx);
} catch (err) {
  if ((err as Error).message === "paperclip_runner_chat_attachment_destination_denied") {
    // deliver to the permitted fallback destination instead of the conversation
    await deliverToFallbackDestination(binding);
    return null;
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling when destinationAllowed fails, typically because: the conversation's resource (channel/board) is not permitted as a reply destination for this endpoint; the conversation's destination configuration (e.g. DM vs channel policy, resourceId scope) is disallowed by endpoint settings; the resource row exists but belongs to a scope the endpoint cannot post to.

Common situations: Endpoint configured to reply only in threads but the resolved conversation targets a channel; conversation resource was deleted/re-pointed after the run started; admin tightened destination policy while a run was waiting; mismatched chatEndpointResources row (different endpointId) so the resource lookup returned null and policy treats it as disallowed.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/b7f309e2d513ecf7. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/native-runtime/chat-attachment-reuse.ts:536

          .from(chatEndpointResources)
          .where(
            and(
              eq(chatEndpointResources.id, conversation.resourceId!),
              eq(chatEndpointResources.companyId, binding.companyId),
              eq(chatEndpointResources.endpointId, endpoint.id),
            ),
          );
        return lockMode === "read"
          ? query.then((rows) => rows[0] ?? null)
          : lockMode === "nonblocking"
            ? query
                .for("update", { noWait: true })
                .then((rows) => rows[0] ?? null)
            : query.for("update").then((rows) => rows[0] ?? null);
      })()
    : null;
  if (!destinationAllowed(endpoint, conversation, resource)) {
    throw new Error("paperclip_runner_chat_attachment_destination_denied");
  }
  for (const principalId of new Set(links.map((row) => row.principalId!))) {
    if (!(await principalAuthorized(tx, endpoint, principalId, lockMode))) {
      throw new Error("paperclip_runner_chat_attachment_principal_denied");
    }
  }
  return { conversationId: conversation.id, endpointId: endpoint.id };
}

function externalChatWaitCandidate(
  contextSnapshot: unknown,
  binding: ChatReuseBinding,
): { provider: string; commentIds: string[] } | null {
  const context = record(contextSnapshot);
  const source = typeof context.source === "string" ? context.source : "";
  const provider = [
    "slack",
    "github",

View on GitHub (pinned to 3f1d897a7c)