feat: add experimental native chat connectors (#13038)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People also ask agents for work in their existing chat tools.
> - Each external conversation needs one task and a current authorized
source.
> - Retries, Stop, and provider failures must not duplicate work or
expose private data.
> - The first chat PR establishes the opt-in provider and data
contracts.
> - This PR adds experimental channel integration and its durable
control plane.
> - Users can request work from connected channels and inspect delivery
in Paperclip.

## Linked Issues or Issue Description

Refs #13100 and #13092. This is the second of exactly two chat PRs.
Foundation #13100 is merged and changed 143 files. Runner prerequisite
#13092 is also merged. This PR changes 400 files against master, below
the 500-file review limit. It contains no wireframe images or HTML
galleries.

## What Changed

- Add native Slack, GitHub, Microsoft Teams, Telegram, and Discord chat
connections. Keep chat disabled unless the operator enables experimental
chat connectors. Preserve the production GitHub tool connection and its
normal setup path.
- Bind each provider bot identity to one immutable Paperclip agent. Bind
each admitted external conversation to one task. Paperclip owns tasks,
runs, permissions, and audit records.
- Add durable admission, per-conversation queues, questions, task
controls, progress, final replies, images, files, and delivery receipts.
Board comments remain internal unless explicitly sent to the channel.
- Check current identity, provider reach, resource access, credentials,
runtime generation, and exact source before provider effects. Keep
private responses private. Never send raw reasoning, private logs,
credentials, or tool arguments.
- Hold uncertain sends for explicit audited resolution. Make Board
Send-to-channel atomic and idempotent. Keep reconnect and setup
credentials in Paperclip secret storage.
- Preserve current native-runner authority across retries, lost
acknowledgements, and recovery. Keep immutable input and completion
contracts separate from newer user input. Receipt reconciliation cannot
launch a provider.
- Reconcile chat close/new ordering and provider-effect lock order.
Audit resource access changes in the same transaction. Submit only the
selected resource from each UI toggle so stale pages cannot undo
unrelated access changes.
- Drain Codex stdout before certifying process exit. Bound the drain
with the existing shutdown grace. Preserve observed terminal authority
without treating an undrained process as successful or reusable.
- Incorporate master `018ca5da` with its ACP Stop, mobile task layout,
runner packaging, and official lock changes. Preserve dedicated
chat-answer continuations in both directions when ordinary queued
comments are adopted after Stop.
- Fence late adapter readiness behind an earlier Stop for the same run.
Preserve verified cleanup for registered adapters. Handle single Stop,
agent pause, duplicate Stops, and failure release without creating a
false cancellation receipt.
- Incorporate master's `6dd48cad4` wake-queue extraction. Preserve exact
failed-chat retry authorization and lineage, retired question-source
suppression, and the block on generic recovery that would discard the
admitted source. Fresh deferred input retains its separate promotion
path.
- Incorporate master `2a05b5ed3` and its queue-admission extraction,
simplified transaction ports, and separate runner CI job. Preserve exact
durable receipts, actor separation, and dedicated-answer isolation
through the new module. A failed receipt insert rolls back the
accompanying deferred-wake merge.

## Verification

Current head: `afe19299d06253cb628eb398e91d1200ea9f412a`, incorporating
master `2a05b5ed3457ea33efd6895520447d1d97fe98d8`. The conflicts are
resolved. This successor fixes two test-harness boundaries exposed by
CI: per-case route-module preparation and actual durable-save completion
before intentional runner termination. Production code and all existing
test/turn deadlines are unchanged. [Exact-head Greptile
review](https://github.com/paperclipai/paperclip/pull/13038#issuecomment-5587250594)
is **5/5**, completed September 10 at 13:20:55 UTC, with no actionable
findings or open review threads. [Fresh exact-head
CI](https://github.com/paperclipai/paperclip/actions/runs/34481724341)
passes **all 24 jobs**, including Build and both required aggregates.
Normal exact-head guarded merge was attempted and rejected by the
remaining branch approval policy: CODEOWNER review is required and no
human approval is present. Normal **squash auto-merge is enabled** as of
September 10 at 13:36:26 UTC. Requested CODEOWNERS have been notified;
no approval bypass or self-approval was used. Earlier-head results below
remain historical evidence, not qualification of this successor.

- Final exact-head Linux evidence: 995/995 chat integration cases; 36/36
agent-skills routes; 35/35 runner live-session cases, including real
process kill/resume; 1948 runner Vitest cases with three existing
benchmark/platform guards; 870/870 API-authority cases; and 104 browser
cases with four existing optional skips. Rust, conformance/replay, full
repository build, typecheck, canary, all server/workspace shards, and
both required aggregates pass with normal CI concurrency. Earlier failed
attempts remain recorded below.

- Latest test-only qualification: 141/141
route/permissions/authentication cases pass in separate cold forks, with
plain server types and independent review clear. The real-runner suite
passes 35/35, with plain runner types and independent review clear. A
controlled premature-save acknowledgement fails as expected; matching
ownership/effect/process evidence, rejected saves, real turn outcome,
test abort, and pre-kill liveness are covered. No local reproduction of
the original CI scheduling failure is claimed. The preceding [CI
run](https://github.com/paperclipai/paperclip/actions/runs/34479680858)
passes 21/24 jobs, including all 995 Linux chat cases and browser
aggregate (104 passed, four existing optional skips); only Build, the
skills serialized shard, and the required verification aggregate fail.
Its exact-head Greptile review was 5/5. Both failed job logs are
retained.

- Final fixture qualification: all eight focused Discord cases and all
995 chat integration cases pass. The exact modal statement/PID is
observed before taking the real connection lock; the test then proves
its actual blocking relationship before mutation. Original SQL
execution, provider behavior, negative assertions, and 1s/15s timeouts
remain unchanged. Independent review is clear and test/production hashes
remain frozen. The preceding [CI
attempt](https://github.com/paperclipai/paperclip/actions/runs/34477184777)
passed 22 jobs, including Build/runner, typecheck, canary, all other
test shards, and browser aggregate (104 passed, four existing optional
skips); the two fixture failures and failed verification aggregate
remain recorded, not relabeled as a pass.

- Current queue-module composition: 308/308 recovery/batching/queue/Stop
tests; 995/995 full chat integration; 89/89 module tests, including real
PostgreSQL receipt-insert rollback; 24/24 workflow/module-boundary
tests; plain server and UI types. All four actual local process/ACP
browser paths pass in 1.4 minutes. Fresh databases, no skips or retries,
stable reviewed source hashes. The initial boundary failure is retained;
its no-op service wrapper was removed without changing recovery context
or weakening the check. An exploratory standalone test-directory
typecheck fails because its new upstream transformation config is not a
standalone typechecking project; standard CI/build does not invoke it,
and no configuration was weakened to suppress those diagnostics.

- The preceding head `e02a63d462ce5d47433b0aeb632bb6fd20aab1ba` passed
[all 24 CI
jobs](https://github.com/paperclipai/paperclip/actions/runs/34436462958)
and exact-head Greptile review at 5/5. Required CODEOWNER review
prevented its normal merge before master advanced again.

- Final extracted-module composition: 307/307 recovery, batching, queue
and Stop-control tests; 995/995 full chat integration; 49/49 module
tests including eight PostgreSQL adapter cases; and 19/19 issue-update
tests. Plain server types pass. All four actual local process/ACP
browser paths pass in 1.3 minutes. Fresh databases, no skips or retries
in these cohorts, frozen source hashes, and independent review clear.

- The preceding head `3e4e1c1c` passes [all PR CI
jobs](https://github.com/paperclipai/paperclip/actions/runs/34415826820),
including Build and required `ci / verify` and `ci / e2e`. Both the
original Rust failure and the previously load-sensitive lineage fixture
pass with unchanged Linux concurrency. Master advanced afterward and
required this reconciliation.
- Final master composition: 448/448 focused UI tests, 186/186 adapter
tests, 24/24 queue/control tests, and 11/11 packaging tests. Plain UI,
server, shared, and adapter types pass. Token gates and diff checks
pass. Independent server and UI reviews are clear.
- Stop-registration regression: both real-service cases fail against
exact `a95` source and pass with the fix. The full corrected
recovery/control suite passes 265/265. Duplicate-owner and failed-Stop
controls also pass. Plain server types pass. The readiness barrier
prevents provider startup without adding an acknowledgment to an already
terminal run.
- Final qualification strengthens terminal-field equality and repeats
both affected cases successfully on a fresh database. All four actual
local process/ACP browser paths pass again in 1.3 minutes, without skips
or retries. The final screenshot shows Cancelled, a paused subtree,
retained input, and no error toast.
- Two new actual-service regressions fail before the merge fix. They
prove that queued-comment adoption could consume a dedicated chat answer
or add unrelated input to that answer. The fixed four-case cohort
passes, including ordinary upstream continuation and adapter Stop
controls. Full recovery passes 257/257. All four actual local
process/ACP Stop browser flows pass in 1.4 minutes, without skips or
retries, on a fresh database.
- The unchanged runner artifact was qualified with 171/171 transport
tests, 870/870 API-authority tests, conformance 1/1, and replay 11/11.
Six controlled reader tests prove the exit/drain repair. Its local
serial Rust workspace passed 546 top-level cases plus two invoked
helpers; the later passing Linux CI supplies default-concurrency
evidence.
- Prior exact-source full chat integration passes 995/995. Settings
regressions cover concurrent stale pages, 501 destinations, pending
state, rejected updates, and explicit retry. These deterministic tests
do not prove live provider behavior.
- Retained failed attempts and their causes are in the [qualification
log](afe19299d0/doc/plans/chat-adapters/2026-09-08-chat-queue-and-webhook-repair.md).
The first merge adapter run timed out while macOS slept for 290 seconds.
Its unchanged repeat passed with a temporary sleep guard. No assertion,
deadline, or CI gate was weakened.

Review commands include `pnpm --filter @paperclipai/server exec vitest
run src/__tests__/heartbeat-process-recovery.test.ts
src/__tests__/issue-queued-comments-routes.test.ts` and `pnpm exec
playwright test --config tests/e2e/playwright.config.ts
tests/e2e/acp-stop-continuation.spec.ts`. Database suites require fresh
disposable databases. See the [browser
runbook](afe19299d0/doc/plans/chat-adapters/2026-09-04-chat-adapters-browser-e2e-runbook.md)
for provider setup and separate live acceptance steps.

## Risks

- This remains experimental. Deterministic tests and bounded live
evidence do not establish every provider feature, tenant, permission
layout, or media shape. Teams work-tenant qualification is still open.
- Failed and uncertain provider effects remain visible and can require
operator action. A transport receipt does not prove recipient
visibility.
- Native controller and runner artifacts must remain compatible.
Preserve lease ownership, terminal authority, source binding, and
quarantine during future changes.
- Access and audit rows commit together, but activity notifications
remain best-effort. This is not a new durable event outbox.
- The PR operation does not deploy a live server, replace its runner, or
change provider permissions. Remaining live qualification is documented
in the [temporary
handoff](afe19299d0/doc/plans/chat-adapters/2026-09-08-open-qualification-followups.md).

## Model Used

OpenAI Codex assisted with implementation, tool execution, testing, and
review. The work records `gpt-6-astra` assistance. The environment does
not report a context-window size. No private reasoning traces are
included.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-10 10:06:45 -05:00 committed by GitHub
parent bce976d60d
commit 889947c238
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
400 changed files with 289856 additions and 34952 deletions

View File

@ -13,6 +13,7 @@ const PRODUCT_ID = "77777777-7777-4777-8777-777777777777";
const INTERACTION_ID = "88888888-8888-4888-8888-888888888888";
const HOLD_ID = "99999999-9999-4999-8999-999999999999";
const ATTACHMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const SECOND_ATTACHMENT_ID = "abababab-abab-4bab-8bab-abababababab";
const LABEL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function createProgram(): Command {
@ -61,6 +62,24 @@ describe("issue subresource commands", () => {
]);
});
it("binds explicit uploaded attachments when adding a comment", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run([
"issue", "comment", ISSUE_ID,
"--body", "The requested files are ready.",
"--attachment-id", ATTACHMENT_ID, SECOND_ATTACHMENT_ID,
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/issues/${ISSUE_ID}/comments`);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
body: "The requested files are ready.",
attachmentIds: [ATTACHMENT_ID, SECOND_ATTACHMENT_ID],
});
});
it("wraps comments, approvals, markers, and recovery action endpoints", async () => {
const fetchMock = vi
.fn()

View File

@ -80,6 +80,7 @@ interface IssueUpdateOptions extends BaseClientOptions {
interface IssueCommentOptions extends BaseClientOptions {
body: string;
attachmentId?: string[];
reopen?: boolean;
resume?: boolean;
}
@ -361,6 +362,10 @@ export function registerIssueCommands(program: Command): void {
.description("Add comment to issue")
.argument("<issueId>", "Issue ID")
.requiredOption("--body <text>", "Comment body")
.option(
"--attachment-id <id...>",
"Bind uploaded issue attachments to this comment",
)
.option("--reopen", "Reopen if issue is done/cancelled")
.option("--resume", "Request explicit follow-up and wake the assignee when resumable")
.action(async (issueId: string, opts: IssueCommentOptions) => {
@ -368,6 +373,7 @@ export function registerIssueCommands(program: Command): void {
const ctx = resolveCommandContext(opts);
const payload = addIssueCommentSchema.parse({
body: opts.body,
attachmentIds: opts.attachmentId,
reopen: opts.reopen,
resume: opts.resume,
});

View File

@ -79,6 +79,11 @@ When a task produces a user-inspectable deliverable file:
4. Link the printed attachment URL in the final issue comment.
5. Then set the final issue status.
For a response that is explicitly intended for an external chat conversation,
also pass each intended file with `paperclipai issue comment --attachment-id
<id>`. Paperclip binds only those exact uploaded files to that comment; other
task attachments remain internal.
Final comments should name and link the uploaded artifact or work product, not
just the local filesystem path. For workspace-only files, include the work
product title and recorded relative path. Local paths can be included as

View File

@ -427,7 +427,7 @@ npx paperclipai issue get <issue-id-or-identifier>
npx paperclipai issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high]
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
npx paperclipai issue delete <issue-id> --yes
npx paperclipai issue comment <issue-id> --body "..." [--reopen]
npx paperclipai issue comment <issue-id> --body "..." [--attachment-id <id...>] [--reopen]
npx paperclipai issue comments <issue-id> [--limit 50]
npx paperclipai issue comment:get <issue-id> <comment-id>
npx paperclipai issue comment:delete <issue-id> <comment-id>

View File

@ -271,6 +271,35 @@ and process-start evidence proves the prior controller is gone, or when the
lease expires. Recovery generation changes do not increment the independent
provider-attempt counter.
## Telegram private draft identities
`chat_telegram_draft_ids` is a content-free, instance-wide PostgreSQL sequence,
not a company-owned record. Telegram's native Stop callback carries a draft ID
but no actor or Paperclip generation. IDs therefore must not be recycled when
a transaction rolls back or an endpoint/company is deleted and its bot is
connected again. The sequence allocates positive 31-bit IDs without cycling;
exhaustion refuses new draft allocation rather than wrapping or falling back to
random IDs. Never reset it as part of chat cleanup.
The matching `chat_actions` entry remains company/endpoint-scoped and binds the
draft to its exact conversation, publication attempt, runtime, credential and
approved text. Stop can suppress that private draft's final publication; it
cannot cancel a task or model run. Logical backups preserve the sequence, but
restoring an older database may roll back its high-water mark: disaster recovery
must not assume stale provider Stop events are safe to reuse. That restore
boundary is not qualified by the rollback/concurrency regression.
## Attachment upload provenance
`issue_attachments.originating_run_id` records server-derived run attribution at
upload time. It is not writable through attachment or work-product update APIs.
Legacy attachments and uploads without a registered run keep a null value; the
migration deliberately does not infer attribution from mutable work products.
Deleting the originating run clears the reference and fails closed for automatic
chat handoff. An agent's external file selection must match the attachment's
company, task, agent, and originating run. Editing or recreating a work-product
record cannot reassign that authority to a later run.
## Question-response delivery receipts
`issue_question_response_deliveries` is the retry-safe, content-free outbox for

View File

@ -16,6 +16,12 @@ Executions with managed GitHub configured receive token-free `git` and `gh` laun
The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process.
Low-trust executions cannot receive raw GitHub credentials, including dedicated
agent tokens. The broker rechecks current agent, project, task, and retained run
policies before credential resolution. An external guest's internal sponsor is
accountable for the task, but does not authorize using the sponsor's account.
Read-only access must use separately authorized tools that enforce that boundary.
Server-side Git operations and GitHub gateway calls follow the same selection rules. Approved gateway operations retain their signed originating identity. Connection audience and tool policies continue to apply to the selected person's connection. Native catalogs remain stable across identity changes, but each invocation resolves the selected grant again. Personal OAuth secret declarations survive connection pauses and metadata edits.
Managed commands disable ambient Git credential helpers, Git global/system configuration, host GitHub CLI configuration, and host SSH identity access. Per-operation GitHub CLI configuration is isolated in a writable configuration directory beneath the managed launcher directory. Missing credentials clear previous author and token values; no teammate, standing delegation, host token, or company-default user's account is substituted. Anonymous/local operations remain available where supported.

View File

@ -0,0 +1,390 @@
# Paperclip Chat Adapters Architecture
**Status:** decision-complete implementation plan
**Date:** 2026-09-03
**Paperclip base:** `origin/master` at `8430bd897f01dd4b91e0970efffb71b97e5a2685`
**Earlier planning references:** `origin/master` was initially observed at `b872cd3d1b404bdaff70af493a2973ceb7e5d6ec`, then refreshed through `112ef5beecf518ce9e0cbbead3eac297c09fc775`, `b84964e5a2fa8b1e6498a1ccb471f6adba97d470`, `7b094724e65c04949706df638d497afb02c84b62`, and `d593463ab6394cd356bf27448ea28bad8cccf4ec`; the implementation branch is rebased onto the SHA above.
**Research snapshots:** Vercel Chat SDK `51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c` (`chat` 4.39.0); OpenTag `6a770d862349f8e996c23c145aef6d6275914a23`
## 1. Decision summary
Paperclip will let a company expose any of its agents through external chat systems without turning those systems into a second control plane. External chat is transport and presentation. Paperclip remains authoritative for agents, tasks, runs, permissions, approvals, budgets, artifacts, liveness, and audit history.
The first release ships Slack, Microsoft Teams, Discord, Telegram, and GitHub. The architecture is registry- and capability-driven so Google Chat, Linear, Notion, WhatsApp, Twilio, X, Messenger, Instagram, email through Resend, iMessage providers, and vetted community adapters can be enabled without redesigning persistence or routing.
The decisive identity choice is **one native bot identity per Paperclip agent endpoint**. A Slack workspace may contain several Paperclip agents, but each is installed as a distinct Slack app/bot and addressed through its native mention. Paperclip will not hide several agents behind one dispatcher bot in v1.
Thread-capable providers use a Hermes-style activation model. A person mentions the bot in the channel's root timeline; the bot creates or opens a native thread rooted at that message, creates exactly one Paperclip issue for its endpoint, and moves the conversation into that thread. Slack and Discord can continue eligible replies in the bound thread without another mention. Teams uses the same post/reply boundary and its required app manifest requests the resource-specific consent needed to deliver unmentioned channel-thread replies; setup cannot complete until the live root-and-reply test proves that grant is effective. GitHub binds an existing issue, pull-request conversation, or inline review-comment thread rather than manufacturing a second GitHub thread.
## 2. Product invariants
1. A channel endpoint belongs to one company, one Apps connection, one adapter, and exactly one Paperclip agent. The endpoint's assigned agent is immutable after creation. Connecting a different agent requires a new connection/endpoint; the setup and connector-detail UI never offers **Change agent**.
2. On a provider with `create_thread_from_root`, a new root-level native mention is an activation envelope: verify it, create or open the provider thread, create the Paperclip issue, reply in the thread, and leave the root timeline quiet except for the provider's normal thread indicator.
3. One bot-owned external thread maps to exactly one Paperclip issue for that endpoint. The binding is idempotent by endpoint plus activation root/thread id. The exceptional case where another Paperclip bot joins through an explicit route still creates a separate related issue because Paperclip preserves single assignment; it may not silently share or steal the first endpoint's issue.
4. Once bound, every eligible human message delivered from that provider thread continues the same Paperclip issue. Slack, Discord, and a correctly installed Teams app need no repeated mention. Paperclip requires and live-verifies Teams RSC rather than exposing a weaker mention-per-turn mode. Telegram privacy-on groups require a reply to the bot or another mention. A fresh unmentioned root message is ignored. A mention inside a pre-existing provider thread may activate that thread when policy allows, but it still yields only one issue binding.
5. Providers without creatable native threads use a declared fallback: use the existing provider conversation/comment thread, or combine the stable conversation with an explicit Paperclip session generation. GitHub uses the existing issue/PR or inline review-comment thread. Telegram forum topics use `message_thread_id`; ordinary Telegram DMs/groups maintain one active issue until `/new`, **New task**, or `/close` advances/closes the binding.
6. A provider's stable direct-message conversation key plus active session generation is the DM issue boundary. The first message creates the active issue; subsequent messages continue it; an explicit new-task action starts a new generation when the provider does not supply multiple native DM threads.
7. The issue remains a normal Paperclip task. Its title, description, status, project, goal, priority, documents, and artifacts remain editable. Its assigned agent is locked to the endpoint agent for the lifetime of the external task. Connecting a different agent requires a new connection and a new external task; there is no normal detach-and-reassign flow.
8. Incoming messages are attributed to an external principal. Linked principals act as their mapped Paperclip user. When enabled in Access, unlinked principals act only through the fixed restricted external profile; the internal sponsoring principal is not a selectable end-user role.
9. Agent execution always uses the assigned Paperclip agent's existing adapter, runtime, permissions, budgets, checkout rules, and approval gates. A channel message never creates a new execution authority.
10. Only a safe, explicitly external publication projection leaves Paperclip. Raw chain-of-thought, internal comments, tool traces, run logs, secrets, hidden activities, and internal identifiers do not.
11. Agent output is eligible for automatic publication. Board comments are Paperclip-only unless their author explicitly chooses **Send to channel**.
12. External agent-to-agent turns are disabled by default. Enabling them requires a directed route, endpoint allowlists, a bounded hop count, self-message suppression, causal fingerprints, and immutable audit events.
13. Bring-your-own provider credentials is sufficient to ship. A managed Add to Slack path can be added later but cannot block the first release.
14. Every active endpoint uses the maximum safe capability set available to its adapter, provider installation, current conversation type, and current Paperclip permission check. Reactions, streaming, rich messages/cards, buttons, modals, commands, files, edits, DMs, and private-response fallbacks are implementation behavior, not per-endpoint on/off settings. Capability negotiation selects the best legal path and degrades unsupported behavior to safe text plus a Paperclip URL; it never bypasses Paperclip authorization.
## 3. Ownership boundary
### 3.1 Native Paperclip chat-adapters subsystem
Chat adapters are part of Paperclip itself, not a bundled or separately installed plugin. The subsystem owns:
- company boundary and actor extraction;
- external-principal authorization contract;
- task creation, single assignment, checkout, wakeup, liveness, and budget gates;
- externally bound task assignment lock and immutable binding lifecycle;
- safe-publication projection and secret/redaction policy;
- attachment ingestion and work-product creation;
- activity records for every mutation;
- public ingress registration and raw-body access needed for signature verification;
- secret references and credential resolution;
- adapter registry and endpoint lifecycle;
- Chat SDK adapter construction;
- provider webhook verification and normalized event conversion;
- provider-thread creation and reconciliation;
- activation/subscription rules;
- conversation and task binding;
- delivery, action, and publication workers;
- provider rendering, streaming, reactions, cards, modals, and fallbacks;
- relay client/server protocol;
- Apps, agent, task, identity-link, and diagnostics UI surfaces;
- first-party schema migrations and lifecycle controls.
### 3.2 Chat SDK
Use Chat SDK for platform-specific normalization and presentation, not as Paperclip's authority. Chat SDK supplies:
- provider adapters and signature helpers;
- mentions, subscribed messages, reactions, slash commands, actions, and modals;
- message/card/file abstractions;
- native streaming where available and post-plus-edit fallbacks elsewhere;
- direct messages and ephemeral-message fallbacks;
- provider capability differences.
Paperclip supplies a database-backed Chat SDK state adapter. In-memory and standalone Redis state may be used in adapter unit tests, but never as the production source of truth for endpoint subscriptions, locks, queues, history, or task bindings.
## 4. Apps model and connection identity
Apps remains the only integration catalog and `/apps` remains the only discovery and setup entry point. Add a `chat_sdk` transport and a `channel` purpose to the connection contract. A provider may expose two separate methods:
- **Chat with an agent** — a channel connection accepting inbound conversation and publishing task output.
- **Use this connection as an agent tool** — the existing tool-connection path, granting provider actions to agents under the existing credential and human-access model.
These methods may share provider branding but never silently share credentials, grants, or identity. The UI must always name which direction is being configured.
The connection-purpose choice is conditional, not a permanent extra wizard step. Show it for every selected provider whose registry entry exposes both chat and tool connection surfaces, not through a provider-name exception. A chat-only provider skips directly to **Which agent do you want to chat with?** using Paperclip's existing single-agent selector. Selection is final for that endpoint.
Provider setup then uses a persistent step-rail wizard with one focused external handoff per phase. The completed agent-selection step remains visible in the rail, but the page body never repeats the selected agent. The wizard preserves completed steps across provider redirects/admin waits and gives every button an explicit consequence. It does not repeat reach, behavior, route, capability, transport, automatic work, or successful verification results. A setup screen may contain only something the operator must click, choose, copy, paste, upload, run, or perform at the provider during that phase. Errors and missing prerequisites appear only when they occur. A successful real provider message completes the connection; **Save & exit** preserves an unfinished draft.
Default installation must minimize exposed credentials while keeping the bring-your-own path complete:
- customer-owned Slack Apps request only Bot User OAuth Token and Signing Secret and treat them as write-only;
- Paperclip generates and stores GitHub's webhook secret, reveals it once for copying to GitHub, and requests only the App ID and private-key PEM;
- Teams requests Client ID, tenant ID, and client secret from the customer-owned Entra App/Azure Bot registration;
- Telegram requests the BotFather bot token because BotFather has no OAuth installation callback.
The customer-owned Slack App path opens a prepared Slack App Manifest, instructs the operator to create and install it, then requests only Bot User OAuth Token and Signing Secret before the channel mention/thread-reply test. A standardized **Add to Slack** handoff may be added later when Paperclip has access to that program; it is optional, may not change runtime authority, and cannot gate the BYO path or release.
All nonessential configuration is post-connect. A chat connection reuses the current connector-detail shell with provider-specific `Settings`, `Access`, `Conversations`, and `Activity` tabs. There is no read-only Overview tab. Settings contain only destination reach that an operator can plausibly change: allowed channels, repositories, chats, or topics, plus direct-message and group-chat toggles where the provider supports those surfaces. The assigned agent, provider account/workspace, task boundaries, activation rules, delivery transport, credentials, installation drift, and response capabilities are not settings.
Provider installation is an availability ceiling, not Paperclip authorization. Slack/Discord/Teams channel membership, Telegram chat membership, and the repositories selected in a GitHub App installation determine the resources whose events the provider can deliver. Paperclip independently enables a subset of those resources. Effective reach is the intersection of provider availability, Paperclip enablement, active endpoint state, and current actor authorization.
The destination used to complete the setup test becomes the connection's first enabled resource because the operator explicitly selected and exercised it. A channel, chat, topic, or repository discovered later appears in Settings as available but disabled. Invitation or installation alone never creates a task or permits a response. Enabling a resource that is not currently available at the provider is rejected with the appropriate provider action, such as **Add Maya to Slack** or **Manage GitHub installation**. Losing provider membership or repository access marks the resource unavailable, blocks new work, and preserves existing task and conversation history.
The management tabs therefore have deliberately separate jobs:
- **Settings** controls where the connection may act inside the provider's available resource set.
- **Access** controls who external people represent. Linked identities use current Paperclip user permissions; allowed unlinked identities use the fixed restricted external profile. The endpoint's sponsoring principal remains an internal authority ceiling and audit field, not ordinary UI configuration.
- **Conversations** is a read-only cross-link list: external conversation, Paperclip task, current state, **Open provider**, and **Open task**. It has no binding controls, detach action, or boundary explainer.
- **Activity** contains delivery health, redacted errors, replay, and contextual repair actions.
The first release makes these product choices instead of exposing policy selectors:
- a root mention in Slack or Teams creates a provider-native thread and one Paperclip task; later replies in that bound thread continue the same task without another mention when the provider delivers them;
- the first mention inside an unbound existing Slack or Teams thread binds that thread to one new task from that point forward and does not import earlier history;
- a DM has one open task at a time; after that task completes, the next message creates a new task, while **New task** or `/new` starts another explicitly;
- a GitHub mention binds the addressed issue, pull-request conversation, or inline review thread to one task;
- Telegram DMs and ordinary groups use one open task at a time, while a forum topic has one stable topic-to-task binding;
- direct verified webhook versus outbound relay is selected by instance deployment and reachability, not by the endpoint operator;
- credential replacement, revoked installations, missing membership, and permission drift appear only as contextual reconnect/repair actions in Activity;
- linked users use current Paperclip permissions, allowed unlinked users receive the fixed restricted external profile, overlapping turns queue, only safe milestones and final output publish, and agent-to-agent routes remain off.
Paperclip always uses the maximum safe provider capability set. Activity owns health, delivery diagnostics, and conditional repair actions. Relay and provider-specific developer transports live under instance administration, not endpoint onboarding.
Each live channel connection has one `chat_endpoints` row. Creating a second bot for another agent creates another connection/endpoint, even inside the same provider workspace. Bot display name and avatar default from the agent, while provider-specific immutable identity fields are displayed separately.
## 5. Persistence model
All records carry `company_id`, timestamps, and appropriate foreign keys. These are first-party Paperclip tables in the normal database schema and migration lifecycle.
### `chat_endpoints`
One-to-one with the parent Apps connection. Fields include adapter slug/version, immutable assigned agent, public endpoint id, provider account/workspace identity, bot identity, internal sponsoring principal, deployment mode (`direct | relay`), lifecycle status (`draft | verifying | active | paused | attention | revoked | archived`), and versioned behavior policy. Credentials are secret references on the parent connection, never inline JSON. The deployment mode is selected by instance reachability/policy and reported to the endpoint; it is not a connector-wizard preference. The sponsoring principal is derived from the connection owner or an instance policy and is not exposed as a normal endpoint setting.
Unique: parent connection; public endpoint id; provider bot identity within an installation where the provider requires it.
### `chat_endpoint_resources`
Provider-available external resources such as Slack channels, Teams conversations, Discord servers/channels, Telegram groups/topics, GitHub repositories, Notion pages, phone numbers, or email domains. Store normalized resource type/id, human label, provider availability (`available | unavailable | removed`), Paperclip enablement, discovery source/time, last verification time, and provider-specific membership/install metadata. Only an available and enabled resource may activate or continue work.
Unique: endpoint plus provider resource type/id.
### `chat_external_principals`
Normalized external users and bots. Store provider tenant/workspace id, provider principal id, principal kind, display metadata, last-seen time, and disabled/deleted markers. Never treat display names or email addresses as identity keys.
Unique: company, adapter, provider tenant, provider principal id.
### `chat_identity_links`
Explicit mapping from an external principal to one Paperclip user, with creator, confirmation time, revocation, and last authorization check. Links are company-scoped and never inferred from matching email alone.
Unique: company plus external principal. A principal has at most one active Paperclip-user mapping in a company.
### `chat_conversations`
Maps endpoint plus normalized external conversation/thread identity to one Paperclip issue. Store conversation kind, thread activation mode, activation root message/event id, provider thread id, provisioning state, subscription state, activation source, issue id, lifecycle (`active | completed | unavailable | endpoint_removed`), latest inbound/outbound ids, and timestamps. A provisional row keyed by the root activation survives a crash between Paperclip issue creation and provider-thread creation and lets reconciliation finish without duplicating either side. Lifecycle changes never unlock agent reassignment or erase the historical link.
Unique: endpoint plus activation root message id; endpoint plus external conversation/thread id. An issue has at most one active binding for the same endpoint.
### `chat_deliveries`
Durable inbound ledger. Store provider event id, normalized kind, raw payload digest, a bounded/redacted normalized envelope, receipt time, processing state (`received | processing | applied | ignored | retrying | failed | dead_letter`), attempt count, lease, result references, and redacted error. The raw provider payload is retained only when explicitly enabled with bounded TTL and encryption.
Unique: endpoint plus provider event id; otherwise endpoint plus deterministic payload fingerprint for providers without stable event ids.
### `chat_message_links`
Maps an inbound comment/action or outbound publication to provider message ids. Store direction, message/thread ids, revision, deletion state, and the Paperclip comment/publication/action reference.
### `chat_publications`
Durable outbound outbox. Store source kind/id, safe payload version, idempotency key, target conversation, rendering plan, lifecycle (`queued | streaming | posted | edited | delivered | retrying | failed | suppressed`), attempt data, and provider result.
Unique: endpoint plus idempotency key.
### `chat_actions`
Stores action/button/select/modal/slash-command callbacks with action id, principal, target interaction or command, payload digest, permission result, exact-once result, and provider acknowledgement.
Unique: endpoint plus provider action id or callback fingerprint.
### `chat_agent_routes`
Directed source-endpoint to destination-endpoint rules. Store activation mode, permitted external resources, maximum hop count, enabled state, and creator. Reject self-routes and cross-company routes.
### `chat_endpoint_leases`
Short durable leases for delivery processing, per-conversation sequencing, publication streaming, and relay ownership. A lease has resource kind/key, owner, fencing token, heartbeat, and expiry.
Unique: endpoint plus resource kind/key.
### `chat_sdk_state`
Versioned endpoint-scoped key/value records for Chat SDK state that cannot safely be derived. Known categories are subscriptions, provider cursors, adapter history, and SDK locks. Keys are bounded and values are schema/version checked.
## 6. Shared contracts and APIs
### 6.1 Shared types
Add stable shared types for:
- `ChatAdapterSlug`, `ChatAdapterMaturity`, and `ChatAdapterCapabilities`;
- `ChatEndpoint`, `ChatEndpointStatus`, and redacted endpoint summaries;
- normalized event kinds: root mention, thread message, subscribed message, DM, reaction, file, edit, delete, action, modal, slash command, lifecycle;
- `ChatActivationPolicy`, `ChatThreadPolicy`, `ChatDmPolicy`, `ChatConcurrencyPolicy`, `ChatProgressPolicy`, `ChatFailurePolicy`, and `ChatPublicationPolicy`;
- thread capabilities and modes: `create_thread_from_root | use_existing_thread | conversation_is_thread`, plus provider thread provisioning/reconciliation state;
- `ExternalPrincipalRef` and external actor attribution;
- safe publication text, artifact, card, interaction, and link blocks;
- delivery/publication state and redacted diagnostics;
- agent-route source stamps and hop metadata;
- adapter setup fields derived from a pinned Chat SDK catalog snapshot.
### 6.2 Company and endpoint APIs
All authenticated APIs are under `/api`, company-scoped, and use existing HTTP/error conventions.
```text
GET|POST /companies/:companyId/chat/endpoints
GET|PATCH|DELETE /chat/endpoints/:endpointId
POST /chat/endpoints/:endpointId/test
POST /chat/endpoints/:endpointId/pause
POST /chat/endpoints/:endpointId/resume
POST /chat/endpoints/:endpointId/reconnect
GET|PUT /chat/endpoints/:endpointId/resources
GET|PUT /chat/endpoints/:endpointId/behavior
GET /chat/endpoints/:endpointId/principals
POST /chat/endpoints/:endpointId/principals/:principalId/link-intent
DELETE /chat/endpoints/:endpointId/principals/:principalId/link
GET|PUT /chat/endpoints/:endpointId/routes
GET /chat/endpoints/:endpointId/conversations
GET /chat/endpoints/:endpointId/deliveries
POST /chat/endpoints/:endpointId/deliveries/:deliveryId/replay
GET /chat/endpoints/:endpointId/publications
GET|POST /chat/endpoints/:endpointId/relay
POST /chat/endpoints/:endpointId/relay/rotate-key
DELETE /chat/endpoints/:endpointId/relay
```
### 6.3 Public ingress and linking
```text
POST /api/chat/webhooks/:publicEndpointId
GET /chat/link/:oneTimeToken
POST /api/chat/link/:oneTimeToken/confirm
```
The endpoint id is random and unguessable but is not treated as the authentication secret. Each adapter verifies the provider signature/token against the exact raw request body before a delivery becomes processable. Verification challenges are handled without starting a task.
One-time identity links are short-lived, single-use, bound to company/endpoint/principal, and completed only after Paperclip authentication. The confirmation page displays both identities and the target company before mutation.
### 6.4 Task APIs
Task responses include a derived, redacted `externalChannelBinding` summary. Add operations to inspect the immutable binding and explicitly publish a board-authored comment or existing eligible output.
```text
GET /issues/:issueId/chat-binding
POST /issues/:issueId/chat-publications
```
Attempting to change `assigneeAgentId` on an externally connected task returns `409 chat_binding_agent_locked` with a safe explanation that a different agent requires a new connection. Removing an endpoint or losing provider access changes the binding lifecycle to unavailable but does not unlock reassignment or erase attribution, messages, publications, or activity.
## 7. Durable event flows
### 7.1 Inbound message
1. Resolve the public endpoint and read the raw request under strict size/time limits.
2. Verify the provider signature/token before parsing untrusted fields for routing.
3. Insert the delivery ledger row and return the provider's acknowledgement within its deadline. Slow work continues from the durable row.
4. Claim the delivery with a fencing lease; duplicate claims return the existing result.
5. Normalize event, tenant, resource, conversation, thread, sender, attachments, and causal ids through Chat SDK.
6. Resolve or create the external principal. Suppress self messages and known outbound echoes.
7. Enforce endpoint status, provider availability, Paperclip resource enablement, rate limits, route policy, and principal authorization. A valid event from an available but disabled resource is recorded as ignored with only the minimum safe metadata; it creates no task, wakes no agent, and sends no response.
8. Apply the adapter's thread policy. For a root mention on Slack, Discord, or a thread-capable Teams channel, claim an activation lease keyed by endpoint plus root event/message id. For GitHub, claim the existing issue/PR/discussion thread. Unaddressed fresh root messages are recorded as ignored.
9. Transactionally create the assigned Paperclip issue and a provisional conversation row before any non-idempotent provider call. The issue includes source metadata and a backlink, but no provider secret.
10. Create/open the native provider thread through Chat SDK and finalize the binding. On Slack, the first bot reply under the root message establishes the thread; on Discord, create a native thread; on Teams, reply within the stable channel-post thread when supported. A crash leaves a reconcilable provisional binding rather than a second issue.
11. For an already bound provider thread, append every eligible human reply to the same issue without requiring another mention. A mention inside an unbound pre-existing thread may bind it once when endpoint policy allows.
12. Persist the incoming message/attachment as an issue comment or typed interaction with immutable external attribution.
13. Use Paperclip's normal wakeup path. Existing checkout, active-run, budget, pause, and liveness rules decide whether work queues, steers, or waits.
14. Publish the acknowledgement and all later output inside the bound provider thread. Use a reaction only as an optional immediate receipt; otherwise use ephemeral or concise threaded output. Record every mutation and final delivery state.
### 7.2 Thread activation modes
| Mode | Providers | Activation and binding |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_thread_from_root` | Slack, Discord, thread-capable Teams channels | A root `@bot` mention creates/opens a native thread and exactly one endpoint-owned Paperclip issue. All output stays in that thread; follow-ups continue there when delivered under the provider's mention/subscription/RSC rules. |
| `use_existing_thread` | GitHub issues, pull-request conversations, inline review-comment threads; providers where the mention is already inside a native thread | The addressed existing thread becomes the external boundary and maps once to one endpoint-owned Paperclip issue. |
| `conversation_is_thread` | Telegram chats/topics and providers without nested threads | A stable topic maps directly; a linear chat combines its stable key with an active session generation advanced by **New task**/`/new`. Activation copy makes the broader visibility explicit. |
Thread creation is capability-driven, never inferred from provider name alone. The registry records whether a surface can create a thread, whether the root message itself is the thread key, and whether bot replies, edits, files, actions, and streaming are legal inside it.
### 7.3 Outbound publication
1. An eligible agent result, interaction, or explicit board **Send to channel** action emits an outbox candidate.
2. The safe-projection service validates visibility and produces a versioned payload containing only external text, approved links, sanitized artifacts, and supported interactions.
3. The publication worker claims the per-conversation lease and renders against adapter capabilities.
4. Prefer native streaming where supported. Otherwise post a working message and edit it at a bounded cadence. If editing is unsupported, post coarse milestones and one final response.
5. Store provider ids after every acknowledged send. Retries use the same idempotency key and edit the known message where possible.
6. On success, link the provider message to the Paperclip source. On terminal failure, retain a visible diagnostic and Paperclip retry control without mutating the task result.
### 7.4 Interactive callback
1. Verify and durably record the action exactly like other ingress.
2. Resolve the external principal and its current Paperclip mapping.
3. Re-read the target task/interaction and its current resolver audience or approval policy.
4. Authorize as the linked Paperclip user. An allowed unlinked principal may answer only non-governed interactions allowed by the restricted profile; it cannot approve, hire, spend, change permissions, change budgets, or reassign agents.
5. Apply the Paperclip mutation transactionally and exactly once. Resolution never implies authorization for its downstream effect.
6. Return an ephemeral/card update where supported or a text result with a Paperclip URL.
## 8. Identity and permission model
### Linked principals
A linked principal becomes a Paperclip user actor only after explicit confirmation. Every action is reauthorized using current membership and permissions; a stale link conveys no cached authority. Activity includes provider/tenant/principal, Paperclip user, endpoint, delivery/action id, and authorization result.
### Restricted external principals
Every endpoint has an internal sponsoring principal and a versioned restricted external profile. The sponsoring principal is derived from the connection owner or instance policy; it is an audit and authority ceiling, not an Access-tab choice. Effective unlinked authority is the intersection of that ceiling, the enabled endpoint resource, restricted-profile operations, and target-state constraints. The initial allowlist is limited to starting/continuing the endpoint's bound task, uploading allowed attachments, and answering explicitly guest-resolvable non-governed questions. Unlinked people cannot use Paperclip as a general API principal and cannot govern the company. The Access tab exposes only whether unlinked participation is allowed and the explicit linked-identity list.
### Agent messages
Messages from another Paperclip bot resolve as external bot principals. They are ignored unless a matching directed route is active. Routed events carry an immutable origin endpoint, publication id, route id, visited endpoint set, and hop count. The receiving endpoint creates/continues its own task. Exceeding the hop limit, revisiting an endpoint, repeating a causal fingerprint, or targeting the source endpoint suppresses the event and writes audit evidence.
## 9. Chat SDK feature policy
The table below is an implementation contract, not a menu of endpoint toggles. For each publication or callback, Paperclip intersects adapter capabilities, provider installation/permission health, conversation type, safe-publication rules, and the current actor's Paperclip authority. It then uses the most capable legal rendering or interaction path automatically.
| Feature | Paperclip behavior |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mentions and thread messages | A root mention creates/opens a provider thread and one issue when supported; later messages in that thread continue without mentions; fresh unaddressed root messages are silent. |
| Streaming | Safe text only; native stream, draft preview, or post/edit fallback selected per adapter. |
| Cards | Render safe artifacts, status, questions, approvals, and links; fall back to text plus Paperclip URL. |
| Actions/dropdowns | Resolve typed Paperclip interactions after identity and permission checks. |
| Modals | Use for provider-supported forms; validate again server-side and fall back to link. |
| Slash commands | Map registered commands to explicit chat-subsystem operations where the adapter exposes command events. Slack `status`, `new`, and `close` controls are DM-scoped because Slack's slash-command payload has a channel id but no native thread timestamp; channel work remains managed from its mention-created thread and Paperclip task link. On Telegram, parse the small `/new`, `/status`, and `/close` vocabulary as ordinary messages. Never treat arbitrary command text as board authority. |
| Emoji/reactions | Use a provider-safe acknowledgement vocabulary; custom emoji is optional. |
| Files | Inbound files use bounded sanitized attachment ingestion; outbound files use signed, expiring content URLs or provider upload. |
| Direct messages | One open task is active per DM conversation. After completion, the next inbound message creates a new task; **New task** or `/new` starts another explicitly. Proactive DM requires endpoint policy and target authorization. |
| Ephemeral messages | Preferred for denials, link prompts, and private receipts; fall back to DM or safe public text. |
| Overlap/concurrency | Support burst, queue, debounce, drop, and concurrent modes; default to queue and serialize task mutation. |
| Edits/deletes | Map provider edits/deletes to append-only correction/tombstone events; never silently rewrite audit history. |
Safe progress states are `queued`, `working`, `waiting_for_input`, `approval_needed`, `completed`, and `failed`. They may name the current task phase or public artifact, but not private prompts, hidden tools, internal logs, or chain-of-thought.
## 10. Deployment model
### Direct mode
Paperclip exposes `/api/chat/webhooks/:publicEndpointId` at a stable HTTPS origin. This is selected automatically for cloud and publicly reachable authenticated/self-hosted instances. Provider credentials and signing material are secret references. Health checks confirm reachability, credential validity, subscription state, and a real test event. The endpoint wizard never asks the user to choose “direct webhook.”
### Relay mode
A private instance opens an outbound authenticated WebSocket to a lightweight relay. Providers send to the relay; the relay verifies its outer endpoint binding and forwards an encrypted, bounded envelope. Paperclip still performs provider signature verification before processing. The relay retains only retry metadata and encrypted payloads for a short configured TTL, has no Paperclip user credential, and cannot invoke arbitrary APIs. Fenced endpoint ownership prevents two connected instances from consuming one delivery. Instance administration selects/configures relay once; individual endpoint wizards inherit it automatically.
### Non-shipped managed installation
Bring-your-own provider credentials are the required first-release default. Managed Slack or GitHub provisioning may later reduce credential handling, but these are optional conveniences rather than separate runtime or permission models and cannot block activation or release.
Slack Socket Mode and Telegram polling are not ordinary endpoint choices. They are instance-level developer/on-premises escape hatches used only when the deployment cannot accept provider callbacks and has no configured relay. Enabling either requires explicit instance administration and provider-specific credentials; normal connector setup continues to say only that delivery is automatic.
## 11. Delivery phases
1. **Core contracts and mock adapter:** shared types, first-party schema and migrations, Paperclip state adapter, public ingress, delivery/outbox workers, safe projection, thread-provisioning state machine, and exhaustive mock-provider tests.
2. **Apps and task surfaces:** extend the existing `/apps` setup shell with the registry-driven conditional purpose choice, immutable single-agent selector, persistent provider step rail, resumable external handoffs, and documented action consequences; add chat-specific connector-detail navigation, the agent Channels view, task binding banner, identity linking, and diagnostics. Delivery and capability mechanics never become onboarding questions.
3. **Slack:** implement the complete guided customer-owned App path, signed callbacks/relay inheritance, root-mention-to-thread activation, one-thread/one-issue binding, threaded follow-ups, DMs, reactions, files, streaming/edit fallback, cards/actions/modals/commands, and a real-workspace harness. Add to Slack remains optional when platform access becomes available.
4. **Teams, Discord, Telegram, and GitHub:** implement adapter-specific setup and capability tests against the same contracts. Teams and Discord exercise native thread behavior; Telegram exercises active linear-chat generations and forum-topic boundaries; GitHub exercises existing issue/PR/review-comment bindings. GitHub Discussions are deferred unless adapter support is added and tested.
5. **Private relay:** outbound registration, rotation, reconnect, backlog limits, and failover diagnostics.
6. **Agent routes:** directed allowlists, causal stamps, loop/hop protection, and multi-bot channel tests.
7. **Catalog expansion:** enable official and reviewed vendor/community adapters by capability and maturity; no schema redesign.
8. **Managed provisioning expansion:** broaden Slack organization deployment and other provider-managed installation paths without changing endpoint identity, task, permission, or transport contracts.
Each phase ships behind endpoint-level maturity flags (`experimental | preview | stable`). Migrations are additive. Pausing an endpoint or disabling chat adapters at the instance level stops new ingress/publications but preserves tasks, comments, attachments, and audit history.
## 12. Test and release gates
The live provider procedure, fixture identities, evidence contract, negative permission cases, cleanup, and per-platform browser steps are defined in [`2026-09-04-chat-adapters-browser-e2e-runbook.md`](./2026-09-04-chat-adapters-browser-e2e-runbook.md). That runbook is the stable-adapter acceptance gate; the lower-level tests below remain independently required.
- Company-boundary tests for every record, API, webhook lookup, replay, identity link, resource-enable action, and route.
- Raw-body signature fixtures and replay/deduplication races for each adapter.
- Transaction and crash-reconciliation tests for root mention, provider-thread creation, one-issue binding, existing-thread activation, immutable assignment locks, and endpoint/resource removal.
- Linked-user, revoked-link, unlinked-disabled, restricted-external, sponsoring-principal-revoked, low-trust, governance-denied, and stale-target authorization tests.
- Publication redaction tests proving secrets, raw traces, hidden comments, and internal-only artifacts never render.
- Retry/idempotency tests for receipt-before-ack, worker crash, provider timeout, duplicate callback, stream resumption, and edit fallback.
- Concurrency tests for all five overlap modes with Paperclip task mutation serialized correctly.
- Attachment tests for size, type, checksum, malware/sanitization hooks, signed URLs, and provider expiration.
- Agent-route tests for default deny, directed allow, self suppression, repeated causal fingerprint, hop bound, and two bots sharing one provider thread.
- Direct and relay deployment tests, including relay disconnect/backlog/credential rotation and competing consumers.
- UI tests for setup, provider-available versus Paperclip-enabled reach, permissions, identity linking, immutable task assignment, explicit publication, conversation cross-links, diagnostics, empty/error/revoked states, and responsive layouts.
- Live smoke per stable adapter: root mention, provider thread creation/opening, exactly one issue, unmentioned threaded follow-up, silent fresh unmentioned root message, DM continuation, file, interaction, progress/final publication, duplicate event, and permission denial.
The first stable release is complete when an operator can connect any active Paperclip agent to Slack, Teams, Discord, Telegram, or GitHub; explicitly enable a subset of provider-available resources; an addressed native thread/object or explicit linear-chat session in that subset creates exactly one bound Paperclip issue for that endpoint; eligible follow-ups continue it using the provider's documented reply/mention rule; the existing Paperclip agent runs it under normal governance; safe output and artifacts return to the same conversation; failures are diagnosable and retryable; and every state transition is auditable. The provider-specific setup, permission, boundary, and fallback contract is maintained in `2026-09-04-chat-adapters-platform-surfaces.md`; the current navigation and UI inventory is maintained in `2026-09-04-chat-adapters-ui-surfaces-v8.md` and `index.html`.

View File

@ -0,0 +1,140 @@
# Chat Adapters Research Notes
**Date:** 2026-09-03
**Paperclip implementation base:** `8430bd897f01dd4b91e0970efffb71b97e5a2685`
**Earlier planning references:** `origin/master` was initially observed at `b872cd3d1b404bdaff70af493a2973ceb7e5d6ec`, then refreshed through `112ef5beecf518ce9e0cbbead3eac297c09fc775`, `b84964e5a2fa8b1e6498a1ccb471f6adba97d470`, `7b094724e65c04949706df638d497afb02c84b62`, and `d593463ab6394cd356bf27448ea28bad8cccf4ec`; the implementation branch is rebased onto the SHA above.
**Vercel Chat SDK snapshot:** `51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c`, package `chat@4.39.0`
**OpenTag snapshot:** `6a770d862349f8e996c23c145aef6d6275914a23`
## Research question
How should Paperclip place its existing agents inside Slack and other external communication systems while preserving Paperclip tasks, runs, permissions, and governance as the source of truth?
## Paperclip baseline
Paperclip is already task/comment-centric rather than a generic chatbot. It has:
- company-scoped agents with independent runtime adapters;
- single-assignee tasks, atomic checkout, wakeups, active-run handling, and liveness recovery;
- users, agent keys, responsible-user attribution, permission grants, review policies, approvals, and budgets;
- issue comments, typed interactions, documents, attachments, work products, and activity history;
- Apps v2 connection, secret, identity, permission, review, test, and activity surfaces;
- first-party Apps, route, job, settings, and UI infrastructure suitable for a native chat-adapters subsystem.
That means a channel integration should not create another agent runtime or conversational database. Its job is to translate external events into governed Paperclip task operations and translate safe Paperclip output back into the provider.
## Vercel Chat SDK
Sources: [repository](https://github.com/vercel/chat), [adapter catalog](https://chat-sdk.dev/adapters), [documentation](https://chat-sdk.dev/docs), [agent-readable index](https://chat-sdk.dev/llms.txt).
### What it contributes
- One TypeScript abstraction over mentions, subscribed messages, reactions, actions, slash commands, modals, messages, threads, cards, files, DMs, and ephemeral replies.
- AI streaming that can select native Slack streaming, Telegram private-chat draft previews, or post/edit fallbacks.
- Explicit overlapping-message policies: burst, queue, debounce, drop, or concurrent processing.
- A static `chat/adapters` catalog containing package names, factory exports, peer dependencies, credential modes, required/optional environment variables, and secret annotations.
- Pluggable state adapters for memory, Redis/ioredis, PostgreSQL, and vendor runtimes.
### Catalog snapshot
The pinned catalog includes official packages for Slack, Teams, Google Chat, Discord, GitHub, Linear, Notion, Telegram, WhatsApp Business Cloud, Twilio, X/XChat, Messenger, Instagram, and Web. It also lists vendor-official or community integrations for Liveblocks, Resend email, Sendblue/iMessage, Zernio, Matrix, Webex, WhatsApp bridges, Lark, Velt, Kapso, Novu, Linq, Photon, Dial, Weixin, LINE, and others.
The catalog should seed Paperclip setup metadata, but it is not a compatibility guarantee. Paperclip must maintain its own reviewed registry with pinned package/version, maturity, deployment compatibility, and feature-test results.
### What Paperclip must not delegate
Chat SDK's subscription, queue, lock, and history abstractions are bot-building conveniences. Paperclip needs stronger durable delivery, task binding, actor authorization, audit, and outbox semantics. A Paperclip state adapter should implement the SDK contract on Paperclip-owned records while leaving Paperclip's delivery ledger authoritative.
### Thread topology and GitHub
Chat SDK normalizes provider threads, but Paperclip must choose what a thread means. The selected model is Hermes-style for channel products: a root `@bot` mention on Slack, Discord, or a compatible Teams channel creates/opens a native provider thread; that thread owns exactly one Paperclip issue for the endpoint; all later conversation stays inside it without repeated mentions. This keeps the channel timeline readable and gives Paperclip a stable task boundary.
GitHub joins the first supported group through its official Chat SDK adapter. Its issue, pull-request, or discussion already is the native conversation thread, so an addressed comment binds that existing thread to one Paperclip issue instead of creating a second GitHub thread. Telegram uses a stable chat/topic boundary where nested threads are unavailable. These differences belong in adapter capabilities, not provider-name conditionals in orchestration code.
## OpenTag
Source: [CopilotKit/OpenTag](https://github.com/CopilotKit/OpenTag).
OpenTag is a complete Channels SDK starter rather than a general control plane. Its useful patterns are:
- a clear managed-versus-self-hosted channel runner boundary;
- platform ingress separated from the long-running agent runtime by an outbound authenticated connection;
- mention activates a thread, follow-ups in that subscribed thread continue, and unmentioned messages in a fresh conversation remain silent;
- sender-aware context, file-aware prompts, rich native output, and resumable confirmation cards;
- diagnostics that distinguish declared channel, platform setup, environment, runtime connectivity, and live delivery;
- explicit warnings about competing runtimes claiming the same delivery identity.
OpenTag binds one visible persona (`AGENT_DISPLAY_NAME`) to one AG-UI agent URL. It does not solve Paperclip's company, multi-agent, task, permissions, budget, or audit model. Its managed Intelligence service owns provider credentials, delivery, state, and concurrency; Paperclip must own those controls itself or through an optional relay that does not become the business authority.
## Claude Tag
Source: [Introducing Claude Tag](https://www.anthropic.com/news/introducing-claude-tag).
Claude Tag presents one shared `@Claude` identity inside a selected Slack channel. People tag it with tasks; it breaks work into stages, uses connected tools/data/codebases, and replies in a Slack thread. Anthropic describes that channel identity as multiplayer: one Claude shares the channel context and conversation with everyone.
That is appropriate for a single product persona. It does not match Paperclip's core identity model, where a company has many independently configured agents with separate roles, runtimes, permissions, managers, and budgets. Paperclip should therefore expose each selected agent as its own provider bot identity. The shared unit is the channel, not a merged Paperclip agent.
## Slack Add to Slack
Source: [Slack's Add to Slack announcement](https://slack.com/blog/news/add-to-slack).
Slack describes Add to Slack as a standardized authorization and deployment bridge from agent builders into a workspace, with platform-handled multi-tenant permission scoping and centralized Slack governance. The examples emphasize individual agents with their own identities, permissions, and audit trails living beside teammates.
This validates a future managed provisioning path, but Paperclip cannot depend on it initially:
- it is Slack-specific while the architecture must cover many providers;
- it simplifies installation, not Paperclip task/run/permission semantics;
- provider workspace permission inheritance does not replace Paperclip authorization;
- self-hosted Paperclip still needs BYO credentials and private-network relay options.
## One bot per agent versus shared bot
| Model | Strength | Failure in Paperclip | Decision |
| ---------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
| One shared bot dispatches to many agents | One installation and credential set | Hidden addressing grammar, ambiguous identity, mixed permissions/audit, unclear output ownership | Do not use for v1 |
| One bot identity per Paperclip agent | Native addressing, visible role, clean task ownership, separate permissions and audit | More provider installations and credential lifecycle | Adopt |
| One fixed product persona | Simple, Claude Tag/OpenTag-like experience | Does not expose the Paperclip company roster | Allow only as one ordinary Paperclip agent endpoint |
Within a shared Slack channel, `@Researcher` and `@Engineer` are separate apps. A root mention creates the native thread and one issue owned by the addressed endpoint; human replies in that thread continue it without another mention. If a second Paperclip bot participates through an explicit route, Paperclip records a separate related single-assignee issue and guarded route provenance rather than stealing or sharing the first issue.
## Provider shape taxonomy
The UI should not clone a wizard for every adapter or expose these patterns as onboarding steps. `/apps` renders one conditional purpose choice, the existing single-agent picker, and one provider handoff. The following taxonomy drives that final handoff and post-connect detail fields:
1. **Workspace app:** Slack, Teams, Google Chat, Discord, Lark. App registration, tenant/workspace selection, webhook/event subscriptions, scopes, and bot identity.
2. **Comment system:** GitHub, Linear, Notion, Liveblocks, Velt. App/token plus repository/page/room scope; comments and mentions form threads.
3. **Bot token:** Telegram and similar systems. Token, webhook secret/mode, group/channel allowlist, username.
4. **Meta messaging:** WhatsApp, Messenger, Instagram, Kapso. Business/page/account identifiers, access/app/verify secrets, webhook registration, messaging windows/templates.
5. **Phone/RCS/iMessage:** Twilio, Sendblue, Linq, Photon, AgentPhone. Sender number/identity, API credential, webhook, media/delivery restrictions.
6. **Public social:** X/XChat. Bot account/OAuth, public mention and DM modes, media and rate-limit constraints.
7. **Email:** Resend. From identity/domain, API/webhook secrets, threading headers, HTML/text and attachment behavior.
8. **Web/embedded comments:** Web adapter and collaboration vendors. Host-supplied user authentication and conversation identity.
## Minimum-setup findings
- **Slack:** a customer-owned App created from a prepared manifest is the required first-release path; the operator installs it, then copies the Bot User OAuth Token and Signing Secret. Add to Slack remains an optional convenience when Paperclip participates in Slack's agent-deployment program and cannot gate release. Slack documents [shareable app-manifest URLs](https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests/) and the [install/token/signing-secret sequence](https://api.slack.com/tutorials/tracks/app-home-and-modals).
- **GitHub:** a customer-owned GitHub App is the required path. Paperclip generates and stores the webhook secret, exposes it once for copying to GitHub, and then accepts the App ID and private-key PEM. Repository selection remains GitHub's installation step. The App Manifest exchange remains a possible future convenience, not a release dependency.
- **Microsoft Teams:** the portable first-release path uses a customer-owned single-tenant Entra App, Azure Bot, and the three identity values entered in Paperclip. No provisioning helper is shipped or required. See the [Teams registration quickstart](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register).
- **Discord:** the portable first-release path uses a customer-owned application bot with Application ID, Server ID, and write-only bot token. Paperclip generates the least-privilege server-pinned `bot` install URL and verifies Message Content Intent, server membership, and effective channel permissions. Discord's outbound Gateway transport requires no public callback or interactions key.
- **Telegram:** BotFather's `/newbot` flow and returned token cannot be removed. Telegram bots also cannot initiate a conversation, so the smallest proof is: paste the token, open the bot, tap Start, and send one private message. See the [BotFather tutorial](https://core.telegram.org/bots/tutorial) and [Telegram bot introduction](https://core.telegram.org/bots).
These findings produce a strict UI rule: if an operator cannot act on information during the current setup phase, omit it. Automatic credential storage, transport selection, capabilities, and successful checks belong outside onboarding.
## Feature-to-Paperclip mapping
| Chat SDK feature | Paperclip source/target | Required guard |
| -------------------------- | ------------------------------------- | ---------------------------------------------------- |
| Mention/subscribed message | Task create/comment/wakeup | Endpoint/resource activation policy |
| Reaction | Receipt or explicit reaction event | Self/loop suppression and capability check |
| Streaming | Safe public run projection | No raw traces; rate/edit limits |
| Card | Artifact, status, interaction, or URL | Safe renderer and text fallback |
| Button/dropdown/modal | Typed interaction resolution | Current identity, resolver audience, exact once |
| Slash command | Explicit channel command | Command allowlist and normal authorization |
| File | Issue attachment/work product | Bounded download, type/hash/sanitize |
| DM | Conversation-bound task | DM policy and stable provider identity |
| Ephemeral reply | Denial/link/receipt | DM or safe normal-message fallback |
| Overlapping messages | Comment queue or steer/new run | Paperclip task/run concurrency remains authoritative |
## Resulting recommendation
Adopt Chat SDK below a native Paperclip channel control plane. Reuse the current `/apps` catalog, connection wizard shell, single-agent selector, and connector-detail navigation. Onboarding asks only for purpose when ambiguous, the agent, and the provider invite/handoff; reviewed defaults create the endpoint, while Channels, Access, Behavior, Conversations, and Activity remain editable afterward. Implement chat adapters directly in Paperclip with a Paperclip-backed Chat SDK state adapter, durable ingress/outbox, provider-thread provisioning, explicit identity linking, sponsored restricted guests, and endpoint-bound issues. Begin with Slack, Teams, Discord, Telegram, and GitHub, but generate setup and capability UI from a reviewed adapter registry so every later adapter is an enablement exercise rather than an architectural fork.

View File

@ -0,0 +1,277 @@
# Chat Adapters UI Surface Specification
**Status:** historical v1 requirements inventory; current product flow is `2026-09-04-chat-adapters-ui-surfaces-v8.md`. Managed-install and helper-first concepts below are not shipped requirements.
**Date:** 2026-09-03
**Paperclip base:** `origin/master` at `8430bd897f01dd4b91e0970efffb71b97e5a2685` (refreshed from earlier planning references through `d593463ab6394cd356bf27448ea28bad8cccf4ec`)
**Historical wireframes:** see the [Git archive](./wireframes-archive.md). The 19-screen inventory below is retained as architecture-coverage history, not the proposed onboarding flow. Generated images are excluded from the PR.
**Archived wireframes:** [v1 SVG snapshot](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters/wireframes) ([archive and regeneration notes](./wireframes-archive.md))
## 1. Information architecture
Channel integrations extend existing Paperclip surfaces rather than adding a new global product area.
- **Apps / Connectors** remains discovery and connection management.
- Chat adapters are a native Paperclip subsystem surfaced through Apps; they are not installed or managed as a plugin.
- A provider with channel support exposes a clearly separate **Talk to an agent here** connection method beside any **Let agents use this app** tool method.
- A channel connection reuses the App detail shell with `Overview`, `Access`, `Behavior`, `Conversations`, and `Activity` views.
- Agent detail adds **Channels** under Runtime, between Tools and Governance.
- Task detail adds a channel-source banner, external actor attribution, outbound-publication state, and detach controls only when bound.
- Identity linking uses a minimal public Paperclip route reached from an ephemeral provider message or DM.
- Private self-host relay configuration lives inside the channel endpoint; it is not a global infrastructure page.
- Slack, Teams, Discord, Telegram, and GitHub are the initial supported set. Adapter capabilities determine whether Paperclip creates a native thread, binds an existing thread, or uses the stable conversation as the issue boundary.
The default audience is a company operator connecting and governing an agent. External participants see their native provider, not these configuration screens.
## 2. Cross-surface rules
- Always name the selected Paperclip agent and provider bot identity together.
- Always distinguish tool access from chat presence.
- State who can trigger the agent, where, and as which Paperclip principal before activation.
- Describe effective permissions; never imply that a provider membership grants Paperclip authority.
- Put safe defaults first: root mention creates/opens a provider thread and one Paperclip issue, threaded replies continue without mentions, queued overlap, public milestones only, linked-user permissions, sponsored restricted guests, agent routes off.
- Hide unsupported configuration and show the provider fallback beside partially supported behavior.
- Never display secrets after save. Show secret labels, source, last rotation, and health only.
- Every failed setup or delivery state says what happened, whether work was accepted, and the next safe action.
- Desktop uses the existing Paperclip primary and contextual sidebars. Mobile uses the existing drawer/header pattern with one full-width content column and 48px actions.
## 3. Screen specifications and annotations
### 01 — Connectors catalog
Purpose: discover providers and see whether each is connected for tools, channels, or both.
1. Existing Apps contextual navigation remains the entry point.
2. Filter chips select All, Tools, Channels, or Connected; search remains provider-wide.
3. Provider rows show separate tool/channel status and endpoint count.
4. Maturity and deployment badges prevent unsupported adapters from looking connectable.
5. Primary action opens provider detail; mobile keeps filters horizontally scrollable and rows stacked.
### 02 — Connection method
Purpose: make directionality unambiguous before credentials are requested.
1. Provider header and current accounts preserve Apps context.
2. Tool method explains the agent calls the provider as an external tool.
3. Channel method explains people message one selected Paperclip agent.
4. Identity/credential warning states that the methods are independently governed.
5. Continue is attached to the selected method; mobile cards become a vertical radio list.
### 03 — Choose agent and bot identity
Purpose: establish the endpoint's permanent Paperclip owner.
1. Wizard progress names the current step and retains a safe exit.
2. Agent selector shows active/invokable agents and their roles.
3. Native bot preview derives name/avatar from the agent and shows provider identity constraints.
4. One-bot-per-agent explanation shows how multiple agents coexist in one channel.
5. Collision/inactive-agent warnings block continuation; mobile preview follows the selector.
### 04 — Provider installation
Purpose: connect a real provider installation without hiding manual work.
1. Setup pattern switcher demonstrates Slack while allowing adapter-generated instructions.
2. BYO setup checklist exposes manifest/app creation, scopes, webhook URL, and event subscription.
3. Credentials are secret-reference fields with masking and source labels.
4. Verification checks signature, bot identity, scopes, and reachability independently.
5. Managed install is visibly optional/unavailable and never blocks BYO continuation.
### 05 — Conversation reach
Purpose: constrain where the bot can listen and explain root-mention-to-thread activation.
1. Workspace/tenant identity is read-only after verification.
2. Resource allowlist supports discovery plus exact external ids.
3. On Slack, Discord, and compatible Teams channels, a root mention creates/opens a native thread and exactly one endpoint-owned Paperclip issue; later thread replies need no mention.
4. DM policy explains its task boundary and proactive-DM restriction.
5. Example panel shows root mention, bot-created thread, threaded follow-up, and ignored fresh root message; mobile puts it in a disclosure.
### 06 — People and permissions
Purpose: establish external-to-Paperclip authority before activation.
1. Endpoint sponsor selection explains why a sponsor is required.
2. Linked-user path maps a provider principal to one Paperclip user after confirmation.
3. Unlinked-user path shows the restricted guest profile and allowed operations.
4. Effective-authority formula visibly intersects sponsor, resource, guest, and target controls.
5. Governance actions are explicitly denied to guests; mobile presents the formula as ordered rows.
### 07 — Output and interaction behavior
Purpose: choose what the bot exposes and how it behaves across provider capabilities.
1. Acknowledgement policy selects reaction, ephemeral, or short-message fallback.
2. Progress policy exposes safe milestones and update cadence, never reasoning traces.
3. Output controls cover final text, artifacts, cards, actions, modals, files, and URLs.
4. Command/reaction/edit/delete behavior is capability-aware.
5. Concurrency selects queue by default plus burst, debounce, drop, or concurrent modes.
### 08 — Agent-to-agent routes
Purpose: make bot-to-bot participation an explicit governed exception.
1. Master control is off by default and explains the risk.
2. Directed route chooses a source endpoint, destination endpoint, and permitted resources.
3. Trigger and maximum-hop controls limit when a bot message activates another agent.
4. Loop-protection summary lists self-message, revisit, fingerprint, and hop suppression.
5. Audit preview shows what route provenance is retained.
### 09 — Review and activate
Purpose: provide one comprehensible safety review and a real delivery test.
1. Readback names agent, bot, workspace, resources, people policy, and behavior.
2. Provider checks distinguish credential, signature, webhook, scope, and bot-membership health.
3. Test message instructions verify root mention, provider-thread creation, one Paperclip issue, unmentioned threaded follow-up, and fresh-root silence.
4. Activation control remains disabled until required checks pass.
5. Managed provisioning notice is informational; BYO completion is sufficient.
### 10 — Endpoint overview
Purpose: answer what is connected, whether it works, and what the operator can do.
1. Header binds agent identity, bot identity, provider installation, and endpoint status.
2. Health summary shows provider, ingress/relay, credentials, and last delivery separately.
3. Activity summary counts conversations, active tasks, failed deliveries, and linked people.
4. Test, pause/resume, reconnect, and open-provider actions are available near status.
5. Remove lives in a distinct danger section and describes task/history retention.
### 11 — Endpoint access
Purpose: manage reachable resources and external identities after setup.
1. Resource allowlist supports enable/disable and verification state.
2. Principal table distinguishes linked user, sponsored guest, bot, revoked, and unknown.
3. Link intent produces a one-time URL without exposing credentials.
4. Sponsor and guest profile changes show their effective impact before save.
5. Revocation stops future user attribution but preserves historical audit identity.
### 12 — Endpoint behavior
Purpose: edit the policies chosen during setup with provider fallbacks visible.
1. Activation, provider-thread creation mode, existing-thread binding, and DM policies are grouped by inbound behavior.
2. Queue/overlap policy names the Paperclip run consequence.
3. Progress, streaming, and publication settings are grouped by outbound behavior.
4. Files/interactions/commands/reactions show supported, fallback, or unavailable states.
5. Save creates a versioned policy and previews material changes.
### 13 — Conversations and tasks
Purpose: inspect the external-thread-to-issue binding ledger and prove the one-thread/one-issue invariant.
1. Rows show provider resource/thread, exactly one endpoint-owned Paperclip issue, participant count, subscription, and activity.
2. Filters cover active, waiting, failed, detached, and DM conversations.
3. Selection opens a detail panel with provider and Paperclip backlinks.
4. Detach explains that history remains and future messages may create a new task.
5. Agent assignment is visible but not editable while bound.
### 14 — Deliveries and diagnostics
Purpose: make ingress/publication failures operable without exposing sensitive payloads.
1. Unified ledger filters inbound, outbound, actions, retries, ignored, and failures.
2. Each row shows event kind, thread/task, state, attempt, timing, and dedupe result.
3. Detail drawer contains redacted normalized fields, provider ids, leases, and error/remediation.
4. Replay is authorized, idempotent, and unavailable for successfully applied mutations.
5. Provider rate limit and relay/ingress health sit above the ledger.
### 15 — Agent Channels view
Purpose: see everywhere a particular Paperclip agent can be reached.
1. Agent contextual navigation adds Channels under Runtime.
2. Endpoint cards show provider bot identity, workspace/resources, health, and trigger policy.
3. Recent externally created tasks link into normal task detail.
4. Add channel starts Apps setup with this agent preselected.
5. Empty state explains that the agent still works normally inside Paperclip.
### 16 — Externally bound task
Purpose: preserve normal task work while making channel ownership and publication explicit.
1. Source banner links to provider conversation and endpoint and explains the assignment lock.
2. External participant comments use provider attribution without impersonating a Paperclip user.
3. Agent output shows queued/streaming/delivered/failed publication state.
4. Board composer defaults to internal; **Send to channel** is an explicit option with preview.
5. Assignee control is locked until detach; the confirmation preserves history and warns about future messages.
### 17 — Identity-link flow
Purpose: safely map one provider principal to the currently authenticated Paperclip user.
1. Landing page shows provider identity, bot/endpoint, company, and expiration.
2. Authentication is required before confirmation and returns to the same intent.
3. Confirmation names both identities; no email-based auto-linking occurs.
4. Success explains that future actions use current Paperclip permissions.
5. Expired, used, revoked, company-mismatch, and wrong-account states provide safe remediation.
### 18 — Self-hosted relay
Purpose: let private instances receive provider events without becoming publicly reachable.
1. Direct and relay modes are compared with current reachability detection.
2. Relay enrollment shows a redacted command/config and a one-time secret handoff.
3. Health shows connection owner, heartbeat, backlog, last delivery, and provider verification.
4. Key rotation and revoke controls explain connection interruption.
5. Offline/degraded states distinguish provider acceptance from Paperclip processing.
### 19 — Adapter and state matrix
Purpose: prove the design generalizes beyond Slack and specify shared empty/error language.
1. Provider taxonomy covers workspace apps, comment systems, bot tokens, Meta messaging, phone/iMessage, public social, email, and embedded web.
2. Capability columns cover mentions/messages, stream/edit, cards/actions/modals, commands, emoji, files, DMs, and ephemeral responses.
3. Setup patterns show which fields are generated from the reviewed adapter registry.
4. Maturity states are experimental, preview, stable, unavailable, and revoked.
5. UI states cover loading, empty, degraded, permission denied, unsupported fallback, rate limited, and dead letter.
## 4. Flow map
`wireframes/flow.svg` connects discovery, method choice, the seven setup decisions, activation, endpoint management, agent view, task view, identity linking, relay setup, diagnostics, detach, and rebind. Solid arrows represent the primary operator path; dashed arrows represent identity, relay, failure, and detach branches.
## 5. Copy and state defaults
- Use **channel connection** for the Paperclip configuration and **bot identity** for the provider-visible account.
- Use **external participant** for an unlinked provider human and **linked user** after confirmation.
- Use **sponsored guest** only in permission explanations, not as the person's display name.
- Default activation on thread-capable channels: “Mention this agent in the channel. It opens a thread and one Paperclip issue; continue in that thread without mentioning it again.”
- Existing-thread activation: “Mention this agent in a GitHub issue, pull request, discussion, or another supported existing thread. That thread binds to one Paperclip issue.”
- Conversation fallback: “This provider has no nested threads; this chat or topic is the Paperclip issue boundary.”
- Default overlap: “Queue messages on this task.”
- Default publication: acknowledgement, coarse safe milestones, final agent output, approved artifacts, and interactions; no reasoning trace.
- Default board composer label: “Internal note”; explicit alternate: “Send to channel.”
- Assignment denial: “This task belongs to the channel connection for {agent}. Detach it before assigning another agent.”
- Guest governance denial: “Link your Paperclip account and use an authorized user, or open this action in Paperclip.”
- Unsupported feature: name the text/link fallback rather than only saying “unsupported.”
## 6. Responsive and accessibility requirements
- Desktop wires use the current Paperclip global/contextual sidebar structure and preserve scanning density.
- Mobile wires use a 375×812 canvas, 16px outer margin, a 48px header/action rhythm, and one content column.
- Tables become stacked summary rows or cards; detail drawers become full-height sheets.
- Wizard steps use a compact progress label rather than a horizontally clipped stepper.
- All key state is expressed in text, not color.
- Annotation red is review-only and not part of the proposed UI.
- Provider icons are grayscale placeholders with visible text labels.
- Long ids, timestamps, delivery ids, and secret labels use the eventual machine-value style; wireframes abbreviate them without presenting real secrets.
## 7. Acceptance matrix
Every architecture capability has a visible place:
- discovery/directional choice: 0102;
- endpoint identity/setup: 0304;
- resource, identity, and permission configuration: 0506, 11;
- complete Chat SDK behavior set: 07, 12, 19;
- agent routing: 08;
- verification and lifecycle: 0910;
- conversation/task binding and publication: 13, 1516;
- durable delivery operations: 14;
- explicit identity linking: 17;
- private self-host deployment: 18;
- provider differences and edge states: 19.
The initial launch matrix is Slack, Microsoft Teams, Discord, Telegram, and GitHub. Slack and Discord use root-mention thread creation; Teams uses that mode on channel surfaces with stable post/reply threads; GitHub binds an existing issue/PR/discussion thread; Telegram uses the stable chat/topic boundary.
No external provider client is wireframed: those products own their UI. The package specifies the Paperclip surfaces and describes provider-visible behavior in annotations and examples.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
# Paperclip Chat Adapters — Minimum Setup v6
Date: 2026-09-04
Paperclip base: `8430bd897f01dd4b91e0970efffb71b97e5a2685`
Historical viewer: [Git archive](./wireframes-archive.md).
Archived wireframes: [v6 SVG snapshot](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters/wireframes-v6) ([archive and regeneration notes](./wireframes-archive.md))
## Relevance rule
A setup screen may show only something the operator must do during that step:
- click a Paperclip or provider action;
- choose something in the provider's UI;
- copy, paste, or upload a required value;
- run a required command;
- send the message that verifies the connection.
Do not show the selected agent again after selection. Do not show automatic credential storage, delivery selection, capability lists, successful checks, resource inventories, or explanatory status rows. Those belong in implementation, Activity diagnostics, or contextual repair states. Show errors and missing prerequisites only when they occur.
The persistent step rail is sufficient context. **Save & exit** preserves the draft. Completing the real provider test activates the connection and treats the explicitly tested destination as its first enabled resource. Any channel, chat, topic, or repository discovered later starts disabled until a Paperclip administrator enables it in Settings.
## Slack
### Required path: customer-owned Slack App
1. **Create and install:** Paperclip opens Slack's official app-from-manifest URL. In Slack, choose the workspace, review the manifest, create the App, and install it.
2. **Connect:** copy **Bot User OAuth Token** from **OAuth & Permissions** and **Signing Secret** from **Basic Information → App Credentials**; paste those two values into Paperclip.
3. **Try Maya:** open a channel, use `/invite @Maya` if required, post `@Maya help me test this` as a new channel message, and reply once in Maya's thread.
The tested Slack channel is enabled when the test succeeds. Inviting Maya to another channel later only makes it available; Paperclip remains silent there until an administrator enables that channel in Settings.
The prepared manifest contains Maya's app identity, callback URLs, least-privilege bot scopes, event subscriptions, interactivity, commands, and file behavior. The operator does not configure those individually. Slack documents [shared manifest URLs](https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests/) and the [install/token/signing-secret locations](https://api.slack.com/tutorials/tracks/app-home-and-modals).
### Non-shipped future convenience
An **Add to Slack** flow may be added later. It is not shipped, is not shown as a current setup option, and cannot gate the first release or replace the customer-owned App path.
## GitHub
### Required customer-owned GitHub App path
1. **Create GitHub App:** copy Paperclip's webhook URL, click **Generate webhook secret**, then create a GitHub App with those values, **Issues: write**, **Pull requests: write**, **Metadata: read**, and the selectable issue/review-comment events. GitHub supplies installation lifecycle events automatically.
2. **Choose repositories:** click **Install in GitHub**, choose the account or organization, choose all or selected repositories, review permissions, and install.
3. **Try Maya:** open an issue or pull request in an installed repository, comment `@paperclip-maya help me test this`, then add another comment to continue the same Paperclip task.
The tested repository is enabled when the test succeeds. Any other repository in the App installation remains disabled in Paperclip until enabled in Settings.
Paperclip returns the webhook secret only once and never exposes it from normal endpoint reads. After GitHub creates the App, the operator enters the App ID and private-key PEM; Paperclip verifies the App permissions and subscribed events before retaining the credentials.
### Existing GitHub App
1. Copy Paperclip's generated webhook URL and one-time secret into the existing GitHub App and make the webhook active. Regenerating rotates the stored secret and requires updating GitHub before further deliveries can verify.
2. Grant **Issues: write**, **Pull requests: write**, and **Metadata: read**; subscribe to **Issue comment** and **Pull request review comment**.
3. Generate a private key in the App settings.
4. Paste the App ID and upload the PEM file to Paperclip, then connect and verify.
5. Continue through GitHub's ordinary repository-installation and test steps.
The webhook secret is generated and already stored by Paperclip; it is copied outward rather than requested back from GitHub.
## Microsoft Teams
### Required customer-owned bot path
1. Copy Paperclip's messaging endpoint.
2. Create a single-tenant Entra App registration and client secret, then create an Azure Bot using the Application ID, enable its Microsoft Teams channel, and set Paperclip's messaging endpoint.
3. In Teams Developer Portal, create the customer-owned Teams app, add the same bot Application ID for Personal, Team, and Group chat scopes, apply the required resource-specific consent entries, and publish or download/upload that app according to tenant policy.
4. Paste the Application ID, Directory/Tenant ID, and client-secret value into Paperclip, then install the customer-owned app in the intended scope.
5. **Try Maya:** open an installed channel, start a new post, send `@Maya help me test this`, and reply once beneath the post.
The tested Teams channel is enabled when the test succeeds. Installing Maya into another team or channel later makes that destination available but does not enable Paperclip work there.
No provisioning helper is part of the shipped path. See the [Teams registration quickstart](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register).
If tenant policy requires administrator approval, Microsoft owns that state inside the same install step. Paperclip preserves the draft; it does not add another configuration page.
Those three identity values are the minimum portable credentials for the manual customer-owned registration. Paperclip does not show authentication-strategy, cloud, webhook, relay, package, scope, or capability choices on the normal path. For tenants that require package submission rather than direct sideloading, Microsoft's publication or installation flow may return an administrator-approval state; Microsoft documents the [custom-app upload and approval paths](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload).
## Discord
### Required customer-owned bot path
1. **Create bot:** open Discord Developer Portal and create one application dedicated to the selected immutable Paperclip agent. Copy its Application ID, add a bot, enable **Message Content Intent**, and paste the bot token only into Paperclip's masked field.
2. **Choose server:** enter the Server ID for the authorized test server. Inspect Paperclip's generated install URL, which must request only the `bot` scope and permission integer `309237763136`; install it in that server without Administrator or `applications.commands`.
3. **Connect:** Paperclip verifies that the token belongs to the Application ID, Message Content is enabled, the bot is installed in the stated server, and at least one text channel has the required effective permissions.
4. **Try Maya:** enable one discovered channel in Access, post `@Maya help me test this` as a new channel message, and reply once in the public thread Maya creates.
Discord uses a direct outbound Gateway connection, so setup does not ask for a public webhook URL, interactions public key, slash-command registration, or delivery choice. Other visible channels remain disabled until explicitly enabled in Paperclip; the direct-message reach switch remains off until an operator enables it.
This customer-owned bot path is the complete first-release setup. There is no managed Discord provisioning path in the current product.
## Telegram
1. **Create bot:** open BotFather, send `/newbot`, enter Maya's display name, choose an available username ending in `bot`, and paste the returned token into Paperclip.
2. **Try Maya:** open the new bot's private chat, tap **Start**, and send `Help me test this`.
The successful private-chat test enables direct messages when setup completes. Groups and forum topics discovered later remain disabled until enabled in Settings.
Telegram has no bot-installation OAuth callback, so the BotFather token is the single unavoidable input. Telegram bots also cannot initiate a conversation; the person must start the bot or add it to a group. See Telegram's [BotFather tutorial](https://core.telegram.org/bots/tutorial) and [bot introduction](https://core.telegram.org/bots).
Group and forum installation is deliberately post-connect configuration. The minimum setup proves a working bot through a private message; an operator can later add the bot to a group and enable the discovered chat in connector Settings. Access remains reserved for external-identity linking and the unlinked-participation policy.
## Resulting screen inventory
| Provider | Normal setup screens | Alternate shipped path |
| --------------- | ------------------------------------------------------------------------------------------: | ------------------------------------------ |
| Slack | Create/install custom App; copy two secrets; Try Maya | None |
| GitHub | Generate secret; configure App; App ID/private key; choose repositories; Try Maya | Existing App uses the same credential path |
| Microsoft Teams | Manual Entra/Azure Bot and Teams app registration; three identity values; install; Try Maya | None |
| Discord | Create bot; Application ID/token/Server ID; install; connect; Try Maya | None |
| Telegram | BotFather token; Try Maya | None |
The linked v6 viewer predates the Discord implementation and remains a four-provider design artifact. The current product and acceptance contract cover all five providers; Discord uses the same Settings, Access, Conversations, and Activity tabs, with the setup path above. The read-only Overview and non-product interaction-walkthrough pages remain absent.

View File

@ -0,0 +1,234 @@
# Chat Adapters — Platform-specific Surfaces
**Status:** detailed wireframe companion
**Date:** 2026-09-04
**Paperclip base:** `origin/master` at `8430bd897f01dd4b91e0970efffb71b97e5a2685`
**Chat SDK snapshot:** `51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c`
**Historical viewer and generated wireframes:** [Git archive](./wireframes-archive.md); images are excluded from the PR. Discord is the current implementation addendum below.
**Current UI companion:** `2026-09-04-chat-adapters-ui-surfaces-v8.md`
**Minimum setup specification:** `2026-09-04-chat-adapters-minimum-setup-v6.md`
**Live browser acceptance:** `2026-09-04-chat-adapters-browser-e2e-runbook.md`
## 1. Shared frame, provider-owned differences
The shared product flow remains deliberately small:
`/apps` → purpose only for a dual-purpose registry entry → choose one immutable agent → provider step-rail wizard → connected.
The provider handoff may have several resumable phases because Slack, GitHub, Microsoft Teams, Discord, and Telegram require different external actions. Each setup page shows only things the operator must click, choose, copy, paste, upload, run, or perform at the provider. The page body never repeats the selected agent and never describes Paperclip's automatic work or successful checks. Errors and missing prerequisites appear only when they occur.
After connection, the existing connector detail shell provides provider-specific **Settings**, **Access**, **Conversations**, and **Activity** tabs. The read-only Overview tab is removed. Settings contains only destination reach that an operator can plausibly change. Task boundaries, provider identities, delivery, credentials, installation drift, and response capabilities are product behavior or contextual Activity repairs—not settings.
The runtime always uses the maximum safe provider capability set. Reactions, streaming, rich messages/cards, buttons, modals, commands, files, edits, DMs, and private-response fallbacks are not per-endpoint feature toggles. Availability is negotiated from the pinned adapter, provider installation and permission health, conversation type, safe-publication policy, and current Paperclip authorization. In the first wave, agent-authored questions and confirmations may degrade to actionless text plus a Paperclip URL when a safe native control is unavailable. Richer Paperclip governance interactions remain Paperclip-only until their complete partial-resolution, terminal-settlement, and recovery semantics are implemented; the connector never emits a provider card it cannot later settle.
The current setup wireframes use the supplied reference image only for its persistent step rail, completed checkmarks, one active phase, and bottom actions. They do not copy its text or function. Provider settings remain ordinary full-width vertical sections and rows. Provider-native interaction models remain behavioral documentation below; the former standalone walkthrough screens are removed because they are not product pages.
### Shared reach and access model
The provider and Paperclip enforce different layers:
1. **Provider availability ceiling:** Slack/Teams/Telegram/Discord decide where the bot is installed or invited; a GitHub App installation decides which repositories are available. Provider permissions and membership determine which events can reach Paperclip at all.
2. **Paperclip resource enablement:** a Paperclip administrator enables a subset of those available channels, chats, topics, or repositories in Settings. An invitation alone is not authorization to create or continue a task.
3. **Actor authorization:** after resource enforcement, a linked identity acts as its current Paperclip user. If the Access toggle allows unlinked people, they receive only the fixed restricted external profile and cannot approve, change budgets, hire, manage permissions/connections, or reassign agents.
The successful setup-test destination becomes the first enabled resource. Newly discovered provider resources appear disabled until explicitly enabled. Provider removal makes a resource unavailable and blocks new work without erasing its tasks or conversation history. Settings therefore answers **where may this bot work?** Access answers **who does this external person represent, and what authority applies?**
Conversations is only a cross-link list. Every row shows the external conversation, Paperclip task, current state, **Open provider**, and **Open task**. There are no binding actions, detach control, detached section, or task-boundary explainer.
| Platform | External install object | Default conversation boundary | Default activation | Output shape |
| --------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
| Slack | Slack app installed to workspace/Grid org | Root message's Slack thread; stable DM conversation | Root `@bot`; replies continue in bound thread | Native stream or post/edit, Block Kit, files, actions, modals, ephemeral |
| GitHub | GitHub App installation on selected repositories | Existing issue, PR conversation, or inline review thread | `@bot` comment in allowed object | GFM comment/reaction/edit; links for files and governed actions |
| Microsoft Teams | Entra/bot registration plus customer-owned Teams app installed to scope | Channel post/replies; stable DM or group-chat conversation | Direct mention by default | Post/edit output; Adaptive Cards/task modules; authenticated file links |
| Discord | Discord application bot installed in one server | Created public thread; stable DM conversation | Root `@bot`; replies continue in bound thread | Post/edit, embeds, buttons, reactions, native files |
| Telegram | BotFather bot token plus chat membership | Active DM/group binding or forum topic | DM message; group `@bot` or reply to bot | Throttled post/edit, optional DM drafts, inline buttons, media |
## 2. Slack
The [pinned Chat SDK Slack adapter](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-slack/README.md) supports single-workspace tokens, multi-workspace OAuth, Enterprise Grid, webhook and Socket Mode ingress, Block Kit interactions, files, DMs, ephemeral replies, and native streaming. Slack independently requires signed-request validation and prompt acknowledgement of [Events API](https://docs.slack.dev/apis/events-api/) and [interactive](https://docs.slack.dev/interactivity/handling-user-interaction/) payloads.
### Setup and external handoff — screen 13
The required customer-owned-App path has two Paperclip screens:
1. **Connect Slack app:** open Slack's app-from-manifest flow, create and install the prepared customer-owned App, then enter its Bot User OAuth Token and Signing Secret write-only in Paperclip.
2. **Try Maya:** open a channel, invite Maya if Slack asks, post a root `@Maya` test message, and reply once in Maya's new thread.
The prepared-App flow contains only required work:
1. Open Slack's app-from-manifest URL, choose the workspace, create the prepared App, then install it from **OAuth & Permissions**.
2. Copy **Bot User OAuth Token** and **Signing Secret** from the documented Slack settings locations and paste those two values into Paperclip.
3. Converge on the same channel mention/thread-reply test.
A managed **Add to Slack** authorization flow is an optional future convenience. It is not shipped, cannot replace the customer-owned-App path, and cannot gate release.
Direct callback versus relay is selected automatically from instance reachability. Socket Mode is removed from endpoint onboarding and exists only as an instance-admin escape hatch when neither a callback nor relay is available. See the minimum-setup specification for the exact effect behind every button.
### Post-connect settings — screen 14
- **Channels:** list channels where the installed bot is already a member and let a Paperclip admin enable or disable each one. The workspace cannot change and appears only as context in channel labels. A later Slack invitation makes a channel available but leaves it disabled until enabled here.
- **Add Maya to another Slack channel:** opens the provider instructions; it changes Slack membership, not Paperclip enablement.
- **Allow direct messages:** one on/off toggle.
- **Fixed behavior:** a root mention creates a Slack thread and one Paperclip task. Replies in that thread continue the task without another mention. The first mention in an existing unbound thread binds that thread without importing earlier history. Fresh unmentioned roots are ignored.
- **Activity repairs:** invalid tokens, missing membership, revoked OAuth, or scope drift appear with a contextual reconnect, invite, or reinstall action only when the condition exists.
Delivery transport, credential rotation, installation drift, task boundaries, receipts, progress, streaming/post-edit output, Block Kit, actions, modals, commands, files, and ephemeral fallbacks do not appear in Settings.
### Runtime interaction model (not a product screen)
1. Ari writes `@maya investigate the refund timeout` as a channel root message.
2. Paperclip verifies the Slack signature, creates the durable delivery, deduplicates the event ID, resolves Ari, checks channel reach/authority, and acknowledges within Slack's deadline.
3. Maya reacts or posts a short receipt under the root. The root's `thread_ts` becomes the external key and binds exactly one issue assigned to Maya.
4. Ari's later thread replies, files, buttons, or modal submissions become turns on that issue. Reactions are deduplicated observational activity only and never create a comment, wake an agent, or convey authority. A modal-opening callback uses a fast acknowledgement path before durable follow-up because Slack trigger IDs expire quickly.
5. Safe output streams or edits inside the thread. Stop/actions resolve through Paperclip permissions. The final publication records its provider message ID; failures become a retryable Paperclip publication, never leaked internal traces.
## 3. GitHub
The [pinned Chat SDK GitHub adapter](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-github/README.md) treats issues and PRs as threads and supports issue/PR/review-comment webhooks. GitHub recommends selecting the [minimum GitHub App permissions](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) and lets installers restrict an app to selected repositories.
### Setup and external handoff — screen 16
The required customer-owned-App path has three screens:
1. **Create or connect GitHub App:** copy Paperclip's webhook URL and one-time generated webhook secret into a customer-owned GitHub App, grant the exact required permissions/events, then enter the App ID and private-key PEM write-only in Paperclip.
2. **Choose repositories:** use GitHub's installation UI to choose the account/organization and all or selected repositories, then install the customer-owned App.
3. **Try Maya:** mention the App in an installed issue or pull request and add another comment to continue the same Paperclip task.
New and existing GitHub Apps use the same manual credential path. Paperclip generates and stores the webhook secret, shows it once for copying to GitHub, and never returns it from normal endpoint reads. The operator supplies only App ID and the PEM file after configuring GitHub. A GitHub App Manifest create-and-return exchange is an optional future convenience and cannot gate release. A PAT is absent from the product setup flow. The chat-purpose App never requests Contents, Actions, Administration, or other code/tool permissions.
### Post-connect settings — screen 17
- **Repositories:** list repositories available to the GitHub App installation and let a Paperclip admin enable or disable each one. A repository added to the installation appears disabled until enabled here.
- **Manage GitHub installation:** opens GitHub's repository-selection UI; it changes provider availability, not Paperclip enablement.
- **Fixed behavior:** direct mention binds an issue, PR conversation, or inline review thread. Those three provider objects use distinct external keys. Label activation and trusted-author automation are omitted from the first release.
- **Activity repairs:** suspended installations, invalid private keys, webhook failures, or permission drift expose contextual repair actions only when detected.
GitHub host, App identity, private keys, surfaces, activation policy, delivery, reactions, GFM output, edits, attachments, and Paperclip-link fallbacks do not appear in Settings. GitHub Discussions remain outside the launch promise until implemented and tested.
The current GitHub chat adapter is text-only for inbound content. A URL written in an issue, pull-request, or review comment remains ordinary comment text; Paperclip does not fetch it, ingest it as a file, or treat it as an attachment. Outbound work products use authenticated Paperclip links because GitHub chat has no native file-upload surface.
### Runtime interaction model (not a product screen)
1. Ari mentions `@maya` in an allowed issue comment, PR conversation comment, or inline review thread.
2. Paperclip validates `X-Hub-Signature-256`, claims the delivery ID, resolves the GitHub principal and installation/repository, applies reach and permission checks, and ignores the app's own comments.
3. The existing GitHub object/thread binds once to a Paperclip issue. The PR conversation and an inline review-comment thread can therefore map to separate Paperclip issues even inside the same PR.
4. Maya adds a receipt reaction and posts one GFM progress comment. Updates edit that comment at a coarse cadence; the final response replaces or completes it.
5. Supported questions and confirmations become explanatory GFM plus a Paperclip URL because GitHub has no native governed-action surface. Richer governance interactions remain Paperclip-only. A request to inspect or modify code runs only if the separately granted GitHub tool connection permits it.
## 4. Microsoft Teams
The [pinned Chat SDK Teams adapter](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-teams/README.md) supports personal, team, and group-chat conversations, Adaptive Cards, targeted messages, and request-scoped DM streaming. Paperclip's production webhook path defers work into its durable queue, so that request-scoped streamer is no longer available when output publishes: the shipped endpoint therefore advertises `nativeStreaming: false` and uses bounded post/edit behavior on every Teams surface. Personal-chat Bot Framework file-download attachments can be ingested through the adapter's scoped bot or anonymous download contract, subject to Paperclip's allowed-content policy and configured attachment ceiling (10 MB by default). Channel and group-chat files remain provider references unless a separate Microsoft Graph connection grants access. The pinned transport has no production-safe deferred binary-upload contract, so outbound files use authenticated Paperclip task links on every Teams surface; it does not claim a native Teams upload. Microsoft's [Teams app registration quickstart](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register) covers the customer-owned app/bot infrastructure, public endpoint, Teams app configuration, and tenant installation policy.
### Setup and external handoff — screen 19
The required path is customer-owned and has two Paperclip screens around provider-owned registration:
1. **Connect Teams app:** copy Paperclip's messaging endpoint; create the single-tenant Entra App, client secret, Azure Bot, and customer-owned Teams app in Microsoft's portals; apply the displayed bot scopes and resource-specific consent entries; then enter Application/Client ID, Directory/Tenant ID, and client secret in Paperclip.
2. **Try Maya:** publish or upload the customer-owned Teams app according to tenant policy, install it in the intended scope, start a new channel post, mention Maya, and reply once beneath the post.
Paperclip does not generate a Teams package or claim to create an install link. It provides an exact Entra, Azure Bot, Teams Developer Portal, and Teams upload field map plus a copyable block of the Paperclip-specific manifest fields. Teams Developer Portal or equivalent Microsoft tooling still owns the complete app metadata, icons, package, publication, approval, and installation. No provisioning helper is shipped or required.
The basic setup does not request organization-wide Graph directory or chat history access. Public versus sovereign cloud and advanced identity are deployment/tenant concerns surfaced only when a real incompatibility occurs. Installation policy and Microsoft admin consent stay inside Microsoft's install experience.
### Post-connect settings — screen 20
- **Channels:** list channels in teams where Maya is installed and let a Paperclip admin enable or disable each one. Tenant and bot identity cannot change; the tenant appears only as channel context. A later Teams installation appears disabled until enabled here.
- **Add Maya to another team:** opens provider instructions; it changes Teams availability, not Paperclip enablement. Channels in the newly installed team then appear disabled in Paperclip.
- **Allow direct messages:** one on/off toggle.
- **Allow group chats:** a separate on/off toggle, off by default.
- **Fixed behavior:** a root channel mention and the replies beneath that post map to one Paperclip task. A personal or group chat has one open task at a time. The next message after completion starts a new task; **New task** starts another explicitly.
- **Activity repairs:** app removal, consent revocation, invalid identity, or endpoint failures expose contextual repair actions only when detected.
RSC, Graph history/directory access, task boundaries, delivery, identity strategy, consent summaries, Adaptive Cards, buttons, task modules, files, reactions, typing, streaming, and buffered/edit behavior do not appear in Settings. Paperclip requests only the minimal provider permission required for the fixed addressed-thread behavior; if Microsoft cannot deliver an unmentioned reply, the conversation asks the person to mention Maya again rather than exposing a policy setting.
### Runtime interaction model (not a product screen)
1. **Channel:** Ari mentions Maya in a new channel post. That root post and its replies are the native thread and bind one Paperclip issue.
2. **DM/group chat:** the stable Teams conversation has one open Paperclip task. After it completes, the next message starts another; **New task** starts another explicitly without pretending there is a channel-style thread.
3. Paperclip verifies the bot activity, tenant, resource, and member; resolves the external principal; checks current permission; then durably appends/wakes the issue.
4. DM, channel, and group output use bounded post/edit behavior and may use Adaptive Cards and task modules. File references require a separate Microsoft Graph connection for ingestion; outbound files otherwise use a safe authenticated Paperclip-link fallback on every Teams surface.
5. Without RSC, unmentioned ambient channel/chat messages are ignored or not delivered. A denied action uses a targeted response when available, otherwise DM or text plus a Paperclip link.
The exact delivery of unmentioned replies in a bound Teams channel thread must be proven against the implementation SDK/manifest. If the bot cannot receive them without RSC, the UI must say **Mention Maya on each reply** or request resource-specific consent; it must not imply a subscription it does not have.
## 5. Discord
Discord uses the pinned Chat SDK Discord adapter through a long-lived Gateway client. It does not receive a public webhook and does not require an interactions public key because the current product has no Discord slash-command or modal surface.
### Setup and external handoff
The complete first-release path is a customer-owned bot:
1. **Connect Discord bot:** create a dedicated application in Discord Developer Portal, copy its Application ID, enable Message Content Intent, enter the authorized Server ID, and paste the bot token write-only into Paperclip.
2. **Install in Discord:** inspect and open Paperclip's server-pinned OAuth URL. It requests only the `bot` scope and permission integer `309237763136`; Administrator, Manage Server, and `applications.commands` are absent.
3. **Try Maya:** enable one discovered text channel, post a root `@Maya` message, and reply once inside the public Discord thread Paperclip creates.
Paperclip verifies that the token belongs to the declared Application ID, the privileged intent is enabled, the bot is installed in the declared server, and usable text channels have the required effective permissions. Application ID is globally unique across active endpoints, including endpoints that name different servers, because one native bot identity cannot represent multiple immutable Paperclip agents.
### Post-connect settings
- **Channels:** list text channels visible to the installed bot and let a Paperclip administrator enable a narrower subset. Newly visible channels remain disabled.
- **Allow direct messages:** one on/off toggle, off by default. Guild threads and DM task generations never share a binding.
- **Fixed behavior:** a root mention creates one public Discord thread and one Paperclip task; eligible replies continue inside it without another mention. A fresh unmentioned root is ignored.
- **Activity repairs:** token rotation, lost server membership, missing Message Content Intent, missing effective channel permissions, Gateway retries, and rate-limit failures appear as contextual diagnostics rather than settings.
There are no endpoint toggles for reactions, post/edit behavior, embeds, buttons, files, lifecycle edits/deletes, reconnect, or retry timing.
### Runtime interaction model and qualification boundary
Discord messages, reactions, interactions, edits, deletes, and partial reaction hydration enter through the Gateway and the same durable delivery/outbox boundary as webhook providers. Safe output uses bounded post/edit behavior; embeds and supported buttons are automatic; file downloads are bounded to reviewed Discord CDN hosts; numeric user IDs are the identity key; callbacks reauthorize against current Paperclip state. Gateway reconnect and provider `retry_after` timing are automatic.
Paperclip completes endpoint, resource, principal, and root-message preflight before any provider-thread side effect. A denied root creates no Discord thread, task, acknowledgement, reply, or run. For an allowed root, Paperclip durably persists a provisional receipt before asking Discord to create the thread; recovery then creates or reuses that thread idempotently and treats Discord error `160004` as existing-thread reconciliation rather than failure. Bounded provider calls and fail-fast compatibility checks keep SDK drift and stalled REST operations visible. These guarantees have deterministic and fresh-database evidence, but files, interactions, Gateway recovery, rate limits, and the root-activation fault paths still require real-provider qualification before Discord can be called stable.
## 6. Telegram
The [pinned Chat SDK Telegram adapter](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-telegram/README.md) supports verified webhooks or polling, files/media, inline buttons, reactions, DMs, throttled post/edit streaming, and opt-in private-chat draft previews. Telegram documents the mutually exclusive [`setWebhook` and `getUpdates`](https://core.telegram.org/bots/api) delivery modes and how [privacy mode](https://core.telegram.org/bots/faq) limits group updates.
### Setup and external handoff — screen 22
The normal path has two screens:
1. **Create Maya:** open BotFather, send `/newbot`, enter Maya's name, choose an available username ending in `bot`, and paste the returned token into Paperclip.
2. **Try Maya:** open the new bot's private chat, tap **Start**, and send one test message.
Telegram has no bot-installation OAuth callback, so the token is the single irreducible credential field. Private chat is the shortest working proof. Group and forum installation moves to post-connect configuration instead of lengthening first setup.
Public/relay production uses a verified webhook chosen by the deployment; local development may use polling. These mutually exclusive modes are instance behavior, not endpoint setup. A leaked token is rotated at BotFather and the Paperclip secret reference is replaced.
### Post-connect settings — screen 23
- **Chats and topics:** list discovered destinations where the bot is present and let a Paperclip admin enable or disable each one. A later Telegram chat/topic discovery appears disabled until enabled here.
- **Add Maya to another Telegram chat:** opens provider instructions; it changes Telegram membership, not Paperclip enablement.
- **Allow direct messages:** one on/off toggle.
- **Fixed behavior:** a DM or ordinary group has one open task at a time; after completion, the next addressed message starts another. `/new` or **New task** starts another explicitly. A forum `message_thread_id` maps one topic to one task. Privacy-on unrelated group traffic is ignored.
- **Activity repairs:** invalid token, lost membership, webhook failures, or flood-control problems expose contextual repair actions only when detected.
Allowed-user lists belong to Access. Task boundaries, BotFather privacy, delivery, relay/polling, token rotation, typing/reactions, post-edit output, private-chat drafts, inline buttons, Markdown, files/media, and safe fallbacks do not appear in Settings.
### Runtime interaction model (not a product screen)
1. **DM:** Ari's first message creates the active issue. An inline **New task** button or `/new` intentionally starts a different issue; ordinary replies continue the active one.
2. **Ordinary group:** `@maya` creates the active binding. Ari must reply to Maya or mention her for later turns. Privacy-on unrelated traffic is not delivered/processed.
3. **Forum group:** the topic's `message_thread_id` is the stable external boundary and can bind one issue. Topic creation is only attempted if configured and authorized.
4. Paperclip validates the secret header or polling claim, deduplicates `update_id`, checks chat/user scope and authority, persists the turn, then sends typing/reaction and throttled progress.
5. Inline callbacks contain a short opaque lookup key, not authority. Paperclip reauthorizes the principal; unsupported or governed actions receive normal text or DM plus an authenticated Paperclip link.
## 7. Wireframe annotations
The numbered red dashed marks in the archived images are review annotations only, not proposed UI. The historical v8 viewer contains 14 minimum setup phases plus four provider management tabs; it contains no interaction-walkthrough pages. Annotation and button-consequence explanations remain in `2026-09-04-chat-adapters-ui-surfaces-v8.md`; its historical setup source data lives in `setup-wireframe-data-v6.mjs` and its historical management source data in `management-wireframe-data-v8.mjs`. The five-provider implementation addenda and live browser runbook are the current product and acceptance sources.
## 8. Implementation acceptance points exposed by the wires
- Provider setup has a persistent step rail and can be paused when external admin action is required, then resumed without creating a second endpoint.
- The selected agent cannot change. Connecting another agent always creates another endpoint.
- Setup page bodies contain only required operator actions and inputs. The completed agent step, automatic Paperclip work, capability lists, and successful checks are not repeated as content.
- Authenticated provider handoffs keep credentials invisible. Manual/customer-owned paths expose only irreducible secrets and store them write-only through Paperclip secret references.
- Delivery transport is selected by deployment and reported as health; direct/relay/Socket/polling are not connector-wizard choices.
- A real provider message completes setup and enables that explicitly exercised destination. Detailed identity, delivery, permission, and capability health appears only when a setup error needs remediation or later in Activity.
- There is no read-only Overview tab. Activity reports health and degradation; Settings contains only provider-available destination enablement and direct/group-chat reach toggles.
- Access contains only the unlinked-participation decision and explicit identity links. The internal sponsoring principal and fixed authority calculation are not normal settings.
- Conversations is a read-only list with provider/task links and row state. It has no manual detach or boundary-management controls.
- Basic operation uses the smallest viable provider permission set. RSC, Graph directory/history, Slack Agent Sessions, Telegram topic administration, and GitHub code access are separate upgrades.
- Every native conversation representation maps to a clear Paperclip issue boundary and gives the user an explicit way to start a new issue on linear-chat platforms.
- Self-message suppression, provider redelivery deduplication, uninstall/revocation, permission drift, rate limits, and provider health appear in Activity even when absent from the happy-path setup.
- Desktop/mobile wires preserve 48px mobile targets and the established Paperclip connector shell.

View File

@ -0,0 +1,152 @@
# Paperclip Chat Adapters — Setup Audit v5
Status: historical snapshot; current setup specification is [`2026-09-04-chat-adapters-minimum-setup-v6.md`](./2026-09-04-chat-adapters-minimum-setup-v6.md)
Date: 2026-09-04
Paperclip base: `7b094724e65c04949706df638d497afb02c84b62`
Historical review viewer: [Git archive](./wireframes-archive.md).
Archived setup wireframes: [v5 SVG snapshot](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters/wireframes-v5) ([archive and regeneration notes](./wireframes-archive.md))
## Decision
Connector setup asks only for decisions or values that Paperclip cannot safely infer, provision, receive from a provider callback, or inherit from the instance deployment.
- The selected agent is displayed as **Locked** throughout setup. A bot identity represents one agent for the lifetime of the connection. Connecting another agent creates another connection.
- Every provider uses a persistent step rail with completed, current, and remaining phases. A provider redirect may leave Paperclip, but the draft and current phase remain resumable.
- Provider-owned approval, organization/workspace choice, repository selection, tenant policy, app installation, and native bot naming remain in the provider's UI.
- Paperclip fixes required events, permissions, callback URLs, command declarations, and maximum safe interaction capabilities. They are not setup options.
- Paperclip selects delivery from instance reachability. Direct callback, relay, Socket Mode, and polling do not appear as endpoint preferences.
- Credentials obtained by an authenticated provider handoff go directly to Paperclip's secret store. They are not displayed or copied through the UI.
- A final live mention or message is encouraged because it proves the real installation, delivery, identity, and conversation boundary. It may be skipped so setup does not block on another person or provider administrator.
## Shared row-by-row disposition
| Previous row or choice | v5 disposition | Reason |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Change agent | Remove; show the assigned agent and **Locked** | Changing it would make an established provider bot identity and historical task bindings ambiguous. Create a new connection for another agent. |
| Bot name/avatar configuration | Show a read-only preview or provider-owned result | Provider naming and uniqueness rules belong in the provider handoff. Paperclip may propose the agent name and avatar. |
| Direct webhook | Remove as a choice | It is the automatic path when the instance has a verified public callback. |
| Private Paperclip / relay | Remove as a choice | A private instance uses its configured outbound relay automatically. Relay enrollment and keys belong to instance administration, not to each endpoint. |
| Slack Socket Mode | Remove from endpoint setup | It requires an app-level token and persistent outbound listener and has distribution constraints. It is an instance-admin development/on-premises escape hatch only. |
| Telegram polling | Remove from endpoint setup | Polling and webhook delivery are mutually exclusive. Paperclip may use polling for a local developer instance, never as a normal endpoint preference. |
| Feature switches for reactions, streaming, cards, actions, modals, commands, files, edits, or DMs | Remove | Paperclip always uses the maximum safe feature supported by the adapter, installation, conversation, and current Paperclip authorization. |
| Event/scopes checklist | Generate and verify; do not expose toggles | Chat connectors need a known least-privilege contract. Missing permissions become a repair state, not an optional configuration. |
| Credentials returned by OAuth or manifest callback | Hide completely | Paperclip can store them directly without asking the operator to handle a secret. |
| Customer-owned credentials with no callback | Keep only the irreducible values; submit write-only | Paperclip cannot authenticate without them. The connector shows secret references and rotation state after setup, never the stored values. |
| Provider resource choice | Keep in the provider handoff | Workspace, organization, repository, tenant, team, channel, group, or chat membership is governed by provider policy. Paperclip may narrow the returned scope later. |
| Send test | Replace synthetic tests with a real native mention/message | A real event proves signature/authentication, installation scope, native identity, routing, and task binding together. |
## Delivery model
| Deployment condition | What Paperclip does | What the endpoint wizard shows |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Paperclip Cloud or publicly reachable self-hosted instance | Registers the endpoint's unguessable verified HTTPS callback directly with the provider. | **Automatic** during setup; read-only delivery health after setup. |
| Private self-hosted instance with Paperclip relay configured | The instance maintains an authenticated outbound relay connection; the relay accepts the provider callback and forwards the verified envelope. | **Automatic** during setup; relay health at instance administration and read-only endpoint diagnostics. |
| Local/developer instance without a public callback or relay | May run a provider-specific escape hatch such as Slack Socket Mode or Telegram polling. | Nothing in normal endpoint setup. The developer enables it once at instance level. |
The direct callback is preferred because it has the fewest moving parts. A private instance cannot receive that callback from Slack, GitHub, Teams, or Telegram; that is the reason a relay exists. Slack Socket Mode establishes an outbound WebSocket using an app-level token, so it avoids a public Request URL but requires a continuously running listener. It is not a competing UX choice. Telegram polling is the analogous local-development fallback and cannot run while a webhook is registered.
## Credentials retained after simplification
| Provider path | Values typed or uploaded by the operator | Why they remain |
| ------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Slack — customer-owned app | Bot token and signing secret | Slack's app-from-manifest handoff preconfigures the App but does not return these two customer-owned values to Paperclip. No webhook URL, app token, or delivery choice is requested. |
| GitHub — customer-owned App | App ID and private-key PEM; GitHub Enterprise Server host only when applicable | Paperclip generates, stores, and reveals the webhook secret once for copying to GitHub. It then authenticates and verifies the App callback, events, and permissions without asking the operator to paste the secret back. |
| Microsoft Teams — customer-owned bot | Application/client ID, tenant ID, client secret | These values come from the customer's Entra App and Azure Bot registration. No provisioning helper is shipped or required. Managed identity remains an instance-level advanced deployment path. |
| Telegram — BotFather bot | Bot token | Telegram has no OAuth or app-manifest installation callback. BotFather gives the operator the bot password once. |
All secrets are write-only inputs to Paperclip's existing secret store. Setup and connector detail retain only secret references, redacted suffixes, health, and rotation actions.
## Slack setup inventory
### Required customer-owned App path
| Screen | Phase | Retained action | What happens |
| ------ | ---------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| 13 | Create Slack app | **Open prefilled Slack setup** | Opens Slack's app-from-manifest URL with identity, callback URLs, scopes, events, interactivity, commands, and files prepared. |
| 13 | Connect app | **Save and verify** | Stores the bot token and signing secret write-only, calls Slack identity APIs, and verifies required scopes. |
| 41 | Try Maya | **Open Slack** | Opens the installed workspace while Paperclip waits for a signed root mention. A valid mention creates the Slack thread and its one Paperclip task. |
| 41 | Try Maya | **Finish without testing** | Activates the endpoint after installation checks and leaves first-event verification visible on Overview. |
Normal Slack setup has only the unavoidable bot-token and signing-secret inputs. Callback, relay, Socket Mode, app-token, event, scope, and feature choices remain absent.
### Optional managed install
An Add to Slack flow can be introduced when Paperclip participates in Slack's managed agent-deployment program. It is a convenience only and is not a first-release dependency.
Slack's OAuth installation redirects through Slack, and its app manifest can create a preconfigured customer-owned app. Socket Mode remains an instance-level exception because Slack documents it as an outbound WebSocket connection using an app-level token and notes distribution limitations. See [Slack OAuth installation](https://docs.slack.dev/authentication/installing-with-oauth/), [Slack App Manifests](https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests/), [Slack Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/), and [Add to Slack](https://slack.com/intl/en-ie/blog/news/add-to-slack).
## GitHub setup inventory
### Required customer-owned App path
| Screen | Phase | Retained action | What happens |
| ------ | ------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 16 | Create GitHub App | **Generate webhook secret** | Paperclip generates and stores a 32-byte secret and reveals it once for copying into the GitHub App. |
| 16 | Connect GitHub App | **Connect and verify** | Accepts the App ID and private-key PEM, authenticates as the App, and verifies the callback, events, and least-privilege permissions. |
| 45 | Choose repositories | **Install in GitHub** | GitHub owns account/organization approval and all-vs-selected repository choice, then returns the installation ID. |
| 46 | Try Maya | **Open GitHub** | Opens an installed repository while Paperclip waits for a signed mention in an issue, PR conversation, or inline review thread. |
| 46 | Try Maya | **Finish without testing** | Activates after App and installation verification; first-delivery status remains on Overview. |
The required path asks only for the App ID and private-key PEM after Paperclip has generated the webhook secret. Contents, Actions, and Administration permissions are absent because this is a chat connection; a GitHub tool connection is separate.
### Existing App
| Screen | Phase | Retained action | What happens |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | --------------- | ------------ |
| An existing App uses the same generated-secret, App ID, and private-key path. Regenerating the webhook secret is an explicit rotation and requires updating GitHub before signed deliveries can resume. |
GitHub's App Manifest exchange remains a possible managed convenience, not a release dependency. GitHub still owns repository installation and scope selection. See [registering a GitHub App from a manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) and [installing a GitHub App from a third party](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party).
## Microsoft Teams setup inventory
Microsoft currently requires more customer-owned infrastructure than the other default paths. v5 does not present multiple authentication or delivery strategies. It chooses a single-tenant client-secret flow for the portable first release and moves managed identity/federation to instance-level advanced deployment.
| Screen | Phase | Retained action | What happens |
| ------ | ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 19 | Register Teams bot | **Open Microsoft setup** | Guides the operator through a customer-owned single-tenant Entra App and Azure Bot registration using Paperclip's messaging endpoint. |
| 19 | Register Teams bot | **Copy messaging endpoint** | Copies the exact public callback to enter in the Azure Bot configuration. |
| 48 | Connect identity | **Save and verify** | Stores the client secret write-only, requests a Microsoft bot token, and verifies tenant, application, and messaging endpoint. |
| 49 | Install app | **Download Teams package** | Downloads a validated ZIP containing public manifest metadata and icons; it contains no secret. |
| 49 | Install app | **Open Teams** | Opens Teams app management for upload/install. Tenant policy decides self-service vs administrator approval. |
| 50 | Try Maya | **Open Microsoft Teams** | Opens Teams while Paperclip waits for the first authenticated activity from an installed scope. |
| 50 | Try Maya | **Finish without testing** | Activates after identity and package checks; installation delivery remains pending on Overview until a real activity arrives. |
Paperclip generates the endpoint, manifest values, and package. Microsoft owns tenant sign-in, Azure/Entra resource creation, app approval, and installation scope. See [Teams SDK registration quickstart](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register), [Teams app authentication](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/essentials/app-authentication/overview), [Azure configuration](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/teams/azure-configuration), and [publishing/installing Teams apps](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/publish).
No Teams provisioning helper is shipped or required; the customer-owned path is complete.
## Telegram setup inventory
| Screen | Phase | Retained action | What happens |
| ------ | ------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 22 | Create Telegram bot | **Connect bot** | Stores the BotFather token write-only, calls `getMe`, fixes the immutable native bot identity, registers commands, and configures deployment-selected delivery. |
| 22 | Create Telegram bot | **Open BotFather** | Opens the provider flow where the operator runs `/newbot`, chooses an available username, and receives the token. |
| 51 | Add to chats | **Open Maya in Telegram** | Opens the bot profile so the operator can start a DM or add it to a group/forum under Telegram membership policy. |
| 52 | Try Maya | **Open Telegram** | Opens Telegram while Paperclip waits for a real update from the intended DM, group, or forum topic. |
| 52 | Try Maya | **Finish without testing** | Activates after bot identity checks and leaves chat-membership delivery pending on Overview. |
Paperclip does not ask for chat IDs up front. It learns stable chat, forum-topic, and participant identifiers from authenticated updates and lets an operator approve them afterward. BotFather's token is unavoidable because Telegram has no OAuth-style bot installation callback. Webhook or local polling selection is automatic. See [Telegram's BotFather tutorial](https://core.telegram.org/bots/tutorial) and [Telegram Bot API webhook/polling contract](https://core.telegram.org/bots/api).
## Purpose choice for dual-surface connectors
Screen 02 is registry-driven, not GitHub-specific. Any connector declaring both `chat` and `tool` methods asks one question:
- **Chat with an agent** enters the chat wizard, selects one immutable agent, and creates a native conversation endpoint.
- **Use this connection as an agent tool** enters Paperclip's existing connection credential and human/agent-access flow.
Connectors declaring only one method skip the choice entirely.
## Setup state and recovery
Each phase persists a draft with the immutable agent, provider handoff nonce, completed checks, expiration, and safe remediation state. Provider returns are idempotent. Refreshing or returning after administrator approval resumes the current phase. Revoked, expired, wrong-company, permission-denied, and provider-error returns explain the corrective action without revealing credentials. Abandoning setup deletes only the unactivated draft; it does not delete a provider resource without a separate explicit action.
## What remains configurable after activation
- Resource reach within the provider installation: channels, repositories, teams/channels, Telegram chats/topics.
- Identity links, endpoint sponsor, and restricted external-person access.
- Conversation activation and task-boundary behavior where the provider genuinely offers alternatives.
- Explicit trusted automation or broader-consent grants, default off.
- Secret rotation only for customer-owned credential paths.
- Pause, reconnect/repair, test, and remove lifecycle actions.
Delivery transport and response capabilities remain status, not preferences.

View File

@ -0,0 +1,597 @@
# Paperclip Chat Adapters UI Surfaces — v6
> Historical revision. The current review is [`2026-09-04-chat-adapters-ui-surfaces-v8.md`](./2026-09-04-chat-adapters-ui-surfaces-v8.md). Managed-install and helper-first concepts below are not shipped requirements.
Date: 2026-09-04
Paperclip base: `7b094724e65c04949706df638d497afb02c84b62`
Historical review viewer: [Git archive](./wireframes-archive.md).
Archived wireframes: [v6 SVG snapshot](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters/wireframes-v6) ([archive and regeneration notes](./wireframes-archive.md))
Minimum-setup specification: [`2026-09-04-chat-adapters-minimum-setup-v6.md`](./2026-09-04-chat-adapters-minimum-setup-v6.md)
## Relevance rule
A setup screen may show only something the operator must click, copy, paste, upload, choose, or perform at the provider during that step. Do not repeat the selected agent, describe automatic Paperclip work, list capabilities, or show successful checks. Errors and unmet prerequisites appear only when they occur.
## Current setup inventory
- Slack: Add to Slack and a three-step customer-owned-App fallback converge on one test screen.
- GitHub: App Manifest creation, repository installation, and test; existing App is an advanced fallback.
- Microsoft Teams: one guided command, one install link, and test; manual Microsoft registration is an advanced fallback.
- Telegram: BotFather token and one private-message test.
- Capabilities and health remain on Overview and the interaction walkthroughs, never in setup.
## Inventory
| ID | Group | Surface | Title | Desktop | Mobile |
| --- | --------------- | ------------------------ | -------------------------------------- | --------- | -------- |
| 01 | Start | Shared | Connectors | 1280×800 | 375×812 |
| 02 | Start | Shared | Choose how to connect | 1280×800 | 375×812 |
| 03 | Start | Shared | Which agent do you want to chat with? | 1280×800 | 375×812 |
| 13 | Slack | Setup | Add Maya to Slack | 1280×800 | 375×812 |
| 42 | Slack | Custom setup | Create and install the Slack app | 1280×800 | 375×1064 |
| 43 | Slack | Custom setup | Connect the Slack app | 1280×800 | 375×1176 |
| 41 | Slack | Setup | Try Maya in Slack | 1280×800 | 375×944 |
| 25 | Slack | Overview | Slack overview | 1280×1472 | 375×1928 |
| 14 | Slack | Settings | Slack settings | 1280×1250 | 375×1676 |
| 26 | Slack | Access | Slack access | 1280×1256 | 375×1592 |
| 27 | Slack | Conversations | Slack conversations | 1280×1160 | 375×1600 |
| 28 | Slack | Activity | Slack activity | 1280×1200 | 375×1640 |
| 15 | Slack | Conversation walkthrough | How Slack conversations work | 1280×960 | 375×1320 |
| 16 | GitHub | Setup | Create Maya in GitHub | 1280×800 | 375×952 |
| 45 | GitHub | Setup | Choose GitHub repositories | 1280×800 | 375×1000 |
| 46 | GitHub | Setup | Try Maya in GitHub | 1280×800 | 375×1000 |
| 47 | GitHub | Custom setup | Connect an existing GitHub App | 1280×960 | 375×1392 |
| 29 | GitHub | Overview | GitHub overview | 1280×1472 | 375×1928 |
| 17 | GitHub | Settings | GitHub settings | 1280×1178 | 375×1564 |
| 30 | GitHub | Access | GitHub access | 1280×1256 | 375×1592 |
| 31 | GitHub | Conversations | GitHub conversations | 1280×1160 | 375×1600 |
| 32 | GitHub | Activity | GitHub activity | 1280×1200 | 375×1640 |
| 18 | GitHub | Conversation walkthrough | How GitHub conversations work | 1280×960 | 375×1320 |
| 19 | Microsoft Teams | Setup | Create Maya for Microsoft Teams | 1280×800 | 375×1080 |
| 49 | Microsoft Teams | Setup | Install Maya in Microsoft Teams | 1280×800 | 375×888 |
| 50 | Microsoft Teams | Setup | Try Maya in Microsoft Teams | 1280×800 | 375×1000 |
| 48 | Microsoft Teams | Custom setup | Set up Microsoft manually | 1280×1064 | 375×1496 |
| 33 | Microsoft Teams | Overview | Microsoft Teams overview | 1280×1472 | 375×1928 |
| 20 | Microsoft Teams | Settings | Microsoft Teams settings | 1280×1322 | 375×1788 |
| 34 | Microsoft Teams | Access | Microsoft Teams access | 1280×1256 | 375×1592 |
| 35 | Microsoft Teams | Conversations | Microsoft Teams conversations | 1280×1160 | 375×1600 |
| 36 | Microsoft Teams | Activity | Microsoft Teams activity | 1280×1200 | 375×1640 |
| 21 | Microsoft Teams | Conversation walkthrough | How Microsoft Teams conversations work | 1280×960 | 375×1320 |
| 22 | Telegram | Setup | Create Maya in Telegram | 1280×800 | 375×1128 |
| 51 | Telegram | Setup | Try Maya in Telegram | 1280×800 | 375×832 |
| 37 | Telegram | Overview | Telegram overview | 1280×1472 | 375×1928 |
| 23 | Telegram | Settings | Telegram settings | 1280×1322 | 375×1788 |
| 38 | Telegram | Access | Telegram access | 1280×1256 | 375×1592 |
| 39 | Telegram | Conversations | Telegram conversations | 1280×1160 | 375×1600 |
| 40 | Telegram | Activity | Telegram activity | 1280×1200 | 375×1640 |
| 24 | Telegram | Conversation walkthrough | How Telegram conversations work | 1280×960 | 375×1320 |
| 11 | Paperclip | Task | Externally bound task | 1280×800 | 375×812 |
| 12 | Paperclip | Agent | Agent Channels | 1280×800 | 375×812 |
## Annotation and action notes
### 01 · Connectors
Purpose: Connect tools and places where people talk to agents.
1. The existing Apps catalog remains the entry point.
2. Filters separate chat and tool methods.
3. Each connector row has one Connect action.
4. Connection state remains visible in the catalog.
Rationale: The current Connectors surface remains canonical.
### 02 · Choose how to connect
Purpose: Shown for every connector that supports both chat and tool methods.
1. The existing connection wizard shell and selected provider are reused.
2. Chat with an agent is the incoming-conversation path.
3. Use this connection as an agent tool is the outbound tool/credential path.
4. Single-purpose providers skip the choice.
Rationale: The registry drives the same direction choice for every dual-surface connector.
### 03 · Which agent do you want to chat with?
Purpose: Choose the one agent represented by this connection.
1. The existing agent selector is reused.
2. Only active agents can be selected.
3. One selection is required.
4. Continue begins provider setup.
Rationale: This is the only shared Paperclip-specific setup decision.
### 13 · Add Maya to Slack
Purpose: Install Maya in your Slack workspace.
1. The step rail is the only repeated setup context; the selected agent is not restated in the page body.
2. The page contains only the installation action and the necessary customer-owned-App fallback.
Actions:
- **Add Maya to Slack:** Opens Slack's Add to Slack flow. The operator chooses a workspace and approves the installation; Slack then returns to the Try Maya step.
- **Set up a custom Slack app:** Opens the customer-owned Slack App instructions for self-hosted deployments or organizations that cannot use Add to Slack.
Rationale: Nothing else on this page requires operator attention.
### 42 · Create and install the Slack app
Purpose: Paperclip prepared a Slack App Manifest for Maya.
1. Every line is an action the operator must complete in Slack.
2. The manifest removes manual scope, event, callback, command, and interactivity configuration.
3. The page advances only after the operator confirms the app was installed.
Actions:
- **Open Slack app setup:** Opens Slack's official app-from-manifest URL with Paperclip's generated manifest encoded in the link.
- **Continue after installing:** Advances to the two credential fields after the operator has installed the new app in Slack.
Rationale: The custom path gives exact provider instructions without exposing Paperclip's automatic configuration.
### 43 · Connect the Slack app
Purpose: Copy two values from the Slack app settings.
1. The only help text tells the operator exactly where to find each required value.
2. Only the two unavoidable Slack credentials are requested.
3. Connecting verifies the values instead of showing a separate verification report.
Actions:
- **Connect Slack app:** Stores both values write-only and verifies the Slack bot identity and required scopes before continuing.
- **Back:** Returns to the Slack creation instructions without saving partially entered values.
Rationale: A customer-owned Slack App cannot return these values to Paperclip, so both fields are necessary.
### 41 · Try Maya in Slack
Purpose: Start one task and reply to it once.
1. The body is only the three actions needed to test the real Slack interaction.
2. The instructions teach the root-mention-to-thread Paperclip task boundary.
3. There is one action: open Slack and perform the test.
Actions:
- **Open Slack:** Opens the installed workspace while Paperclip waits for the root mention and thread reply to complete setup.
Rationale: Installation health and automatic verification do not belong on an instruction screen.
### 25 · Slack overview
Purpose: Identity, health, capabilities, and lifecycle.
1. The endpoint keeps one Paperclip agent and one provider-native bot identity together.
2. Installation and delivery health are summarized before any configuration detail.
3. Every safe capability available to this provider is included automatically; this is status, not a set of switches.
4. Test, pause, reconnect, and remove remain ordinary connector lifecycle actions.
Rationale: Overview remains provider-specific and outside onboarding.
### 14 · Slack settings
Purpose: Scope, task boundaries, and necessary provider operations.
1. Reach is an operator choice and is always bounded by the Slack installation and actual bot membership.
2. Root mention, native thread creation, subscribed replies, and DM task boundaries are explicit.
3. Delivery is read-only status; only credential rotation and installation repair require operator action here. Slack capabilities are reported on Overview and demonstrated in the walkthrough, never configured here.
Rationale: Settings remains provider-specific and outside onboarding.
### 26 · Slack access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Access remains provider-specific and outside onboarding.
### 27 · Slack conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Conversations remains provider-specific and outside onboarding.
### 28 · Slack activity
Purpose: Provider health, deliveries, publications, and retries.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Activity remains provider-specific and outside onboarding.
### 15 · How Slack conversations work
Purpose: The provider-native interaction and fallback model.
1. Ari starts in a Slack channel with a root @maya mention; unrelated root messages do not start work.
2. Maya acknowledges inside a Slack thread, making the thread—not the channel—the visible conversation boundary.
3. Paperclip creates exactly one assigned issue and shows its Slack source, external participant, and publication state.
4. Ari continues by replying in the same thread without another mention; files and actions remain in that context.
5. Maya's safe progress and final answer publish in the thread; failures offer retry or a Paperclip link.
Rationale: Capabilities are demonstrated here, not configured during setup.
### 16 · Create Maya in GitHub
Purpose: Create a dedicated GitHub App from Paperclip's prepared manifest.
1. Only the two choices GitHub presents during App creation are described.
2. The normal action uses the GitHub App Manifest handoff; credentials never pass through the operator.
3. The existing-App branch remains available without cluttering the default path.
Actions:
- **Create in GitHub:** Posts Paperclip's App Manifest to GitHub. GitHub returns to Paperclip after creation, and Paperclip stores the returned App credentials.
- **Use an existing GitHub App:** Opens the advanced path for an App the organization already owns.
Rationale: The manifest already fixes permissions, events, and webhook configuration.
### 45 · Choose GitHub repositories
Purpose: Install Maya where people should be able to mention it.
1. The screen contains only GitHub's installation decisions.
2. Repository scope stays in GitHub's native approval UI.
3. One button begins the complete provider-owned installation step.
Actions:
- **Install in GitHub:** Opens GitHub's App installation page and returns the installation and selected repository IDs to Paperclip.
Rationale: There is no Paperclip form to duplicate GitHub's repository picker.
### 46 · Try Maya in GitHub
Purpose: Start one task in an installed repository.
1. The body is only the native GitHub test sequence.
2. The instructions explain that GitHub's existing issue or pull request is the task boundary.
3. There is one action: open GitHub and perform the test.
Actions:
- **Open GitHub:** Opens an installed repository while Paperclip waits for the first signed mention to complete setup.
Rationale: A real mention proves the App installation without a separate verification screen.
### 47 · Connect an existing GitHub App
Purpose: Update the App in GitHub, then provide its identity credentials.
1. The copy control provides the exact values the operator must paste into GitHub.
2. The instructions list every provider change required for an existing App.
3. Only App ID and private key return to Paperclip; the generated webhook secret is already stored.
4. Verification happens as part of Connect rather than on another screen.
Actions:
- **Copy Paperclip webhook settings:** Copies the endpoint URL and generated webhook secret needed in the existing GitHub App settings.
- **Connect and verify:** Stores the PEM file write-only, authenticates as the App, and verifies webhook, events, and least-privilege permissions.
- **Back:** Returns to the credential-free App Manifest path.
Rationale: Existing Apps lack the manifest callback, so this advanced page contains the complete minimum manual configuration.
### 29 · GitHub overview
Purpose: Identity, health, capabilities, and lifecycle.
1. The endpoint keeps one Paperclip agent and one provider-native bot identity together.
2. Installation and delivery health are summarized before any configuration detail.
3. Every safe capability available to this provider is included automatically; this is status, not a set of switches.
4. Test, pause, reconnect, and remove remain ordinary connector lifecycle actions.
Rationale: Overview remains provider-specific and outside onboarding.
### 17 · GitHub settings
Purpose: Scope, task boundaries, and necessary provider operations.
1. Repository and conversation-surface reach are the only content-scope choices.
2. Existing GitHub objects supply the issue boundary; optional non-mention activation remains an explicit workflow choice.
3. Host, private-key rotation, and installation drift are operational settings. GitHub response capabilities are reported on Overview and demonstrated in the walkthrough, never configured here.
Rationale: Settings remains provider-specific and outside onboarding.
### 30 · GitHub access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Access remains provider-specific and outside onboarding.
### 31 · GitHub conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Conversations remains provider-specific and outside onboarding.
### 32 · GitHub activity
Purpose: Provider health, deliveries, publications, and retries.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Activity remains provider-specific and outside onboarding.
### 18 · How GitHub conversations work
Purpose: The provider-native interaction and fallback model.
1. Ari mentions the bot in an existing GitHub issue, PR conversation, or inline review thread.
2. Maya acknowledges with a reaction and one GitHub-Flavored Markdown comment rather than opening another thread.
3. Paperclip binds that exact GitHub object or review thread to one assigned issue; PR conversation and inline review stay distinct.
4. Later comments continue the same issue, while bot-authored comments and duplicate deliveries are ignored.
5. Progress edits the existing comment; files and governed actions use authenticated Paperclip links.
Rationale: Capabilities are demonstrated here, not configured during setup.
### 19 · Create Maya for Microsoft Teams
Purpose: Run one command to register Maya with Microsoft.
1. The generated command is the only normal-path configuration artifact.
2. Both instructions are actions the operator performs locally or in Microsoft's login.
3. The manual path is available without exposing Azure choices on the default screen.
Actions:
- **Copy setup command:** Copies a one-time Paperclip command that invokes Microsoft's Teams Developer CLI, signs the operator in, creates the Teams App and bot registration, and sends the resulting identity to this setup draft.
- **Set up Microsoft manually:** Opens the Azure/Teams manual fallback for tenants that cannot run the guided command.
Rationale: The helper collapses Microsoft registration into one attended command while Microsoft remains the authority for sign-in and tenant policy.
### 49 · Install Maya in Microsoft Teams
Purpose: Open the Microsoft install page and add the app.
1. The install link replaces package download and upload on the normal path.
2. The body contains only the two actions performed in Microsoft Teams.
3. Tenant approval is handled by Microsoft's install experience, not another Paperclip choice.
Actions:
- **Install Maya in Teams:** Opens the install link returned by Microsoft. Tenant policy may route the same request to an administrator for approval.
Rationale: Microsoft's CLI returns an install link, so normal setup should use it directly.
### 50 · Try Maya in Microsoft Teams
Purpose: Start one task in a channel post.
1. The body is only the Teams channel test sequence.
2. The instructions teach the channel-post-and-replies task boundary.
3. There is one action: open Teams and perform the test.
Actions:
- **Open Microsoft Teams:** Opens Teams while Paperclip waits for the first authenticated mention and reply to complete setup.
Rationale: The final provider event is the verification; no installation report is shown first.
### 48 · Set up Microsoft manually
Purpose: Create the bot in Microsoft, then paste the three identity values.
1. The copy control provides the one Paperclip value required by Microsoft.
2. Every instruction is a portal operation the tenant administrator must perform.
3. The three fields are the minimum identity values Paperclip needs to send as the bot.
4. Connect verifies the identity and produces the same install step as the default flow.
Actions:
- **Copy Paperclip endpoint:** Copies the public messaging endpoint that must be entered on the Azure Bot resource.
- **Connect and create Teams app:** Stores the client secret write-only, verifies Microsoft bot authentication, and creates the installable Teams app and install link.
- **Back:** Returns to the guided one-command setup.
Rationale: The manual fallback is longer because Microsoft has no manifest callback equivalent; no optional Azure choices are exposed.
### 33 · Microsoft Teams overview
Purpose: Identity, health, capabilities, and lifecycle.
1. The endpoint keeps one Paperclip agent and one provider-native bot identity together.
2. Installation and delivery health are summarized before any configuration detail.
3. Every safe capability available to this provider is included automatically; this is status, not a set of switches.
4. Test, pause, reconnect, and remove remain ordinary connector lifecycle actions.
Rationale: Overview remains provider-specific and outside onboarding.
### 20 · Microsoft Teams settings
Purpose: Scope, task boundaries, and necessary provider operations.
1. Tenant, installed team/channel, personal, and group-chat reach are explicit scope choices.
2. Channel threads and linear-conversation active tasks are different, visible issue boundaries.
3. Bot identity, RSC, Graph consent, and installation drift are the only provider-level operations. Teams capabilities are reported on Overview and demonstrated in the walkthrough, never configured here.
Rationale: Settings remains provider-specific and outside onboarding.
### 34 · Microsoft Teams access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Access remains provider-specific and outside onboarding.
### 35 · Microsoft Teams conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Conversations remains provider-specific and outside onboarding.
### 36 · Microsoft Teams activity
Purpose: Provider health, deliveries, publications, and retries.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Activity remains provider-specific and outside onboarding.
### 21 · How Microsoft Teams conversations work
Purpose: The provider-native interaction and fallback model.
1. Ari mentions Maya in a new Teams channel post; that post and its replies are the native thread.
2. Maya acknowledges under the post. If the installed permissions cannot deliver unmentioned replies, the bot says to mention Maya again.
3. Paperclip creates one assigned issue and records tenant, team/channel, thread, and external participant attribution.
4. Replies, files, and Adaptive Card or task-module actions continue only when current Teams delivery and Paperclip permissions allow.
5. DMs may stream natively; channel and group output buffers or edits, with targeted-message, DM, or text-link fallback.
Rationale: Capabilities are demonstrated here, not configured during setup.
### 22 · Create Maya in Telegram
Purpose: Create the bot with BotFather and paste its token.
1. The page contains the exact three BotFather actions.
2. The bot token is Telegram's only unavoidable setup input.
3. The two buttons let the operator leave for BotFather and connect after returning.
Actions:
- **Open BotFather:** Opens Telegram's verified BotFather conversation so the operator can run /newbot.
- **Connect bot:** Stores the token write-only, verifies the bot with getMe, and continues to the test step.
Rationale: Webhook, polling, commands, and identity checks are automatic and therefore absent.
### 51 · Try Maya in Telegram
Purpose: Send the bot its first message.
1. The minimum proof is one private message; group and forum reach can be added after connection.
2. The body contains only the two Telegram actions required for the test.
3. There is one action: open the bot and send the message.
Actions:
- **Open Maya in Telegram:** Opens the bot's t.me link while Paperclip waits for the first verified private message to complete setup.
Rationale: A private chat is Telegram's shortest path from BotFather token to a working Paperclip conversation.
### 37 · Telegram overview
Purpose: Identity, health, capabilities, and lifecycle.
1. The endpoint keeps one Paperclip agent and one provider-native bot identity together.
2. Installation and delivery health are summarized before any configuration detail.
3. Every safe capability available to this provider is included automatically; this is status, not a set of switches.
4. Test, pause, reconnect, and remove remain ordinary connector lifecycle actions.
Rationale: Overview remains provider-specific and outside onboarding.
### 23 · Telegram settings
Purpose: Scope, task boundaries, and necessary provider operations.
1. Chat, topic, DM, and optional user reach are real scope choices.
2. DM/group active tasks and forum-topic bindings make Telegram's non-Slack boundaries explicit.
3. Delivery is read-only status; privacy mode and token rotation are the only provider operations exposed here. Telegram capabilities are reported on Overview and demonstrated in the walkthrough, never configured here.
Rationale: Settings remains provider-specific and outside onboarding.
### 38 · Telegram access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Access remains provider-specific and outside onboarding.
### 39 · Telegram conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Conversations remains provider-specific and outside onboarding.
### 40 · Telegram activity
Purpose: Provider health, deliveries, publications, and retries.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Activity remains provider-specific and outside onboarding.
### 24 · How Telegram conversations work
Purpose: The provider-native interaction and fallback model.
1. In a DM, Ari's first message creates the active issue; New task or /new deliberately starts another.
2. In a privacy-on group, @maya starts work and replying to Maya continues; unrelated group traffic is not consumed.
3. A forum topic can bind one issue through message_thread_id when the bot is present and allowed.
4. Paperclip shows the active issue and makes the linear-chat boundary explicit instead of implying a Slack-style native thread.
5. Maya uses throttled post/edit and inline buttons; unsupported or governed actions return text or DM with a Paperclip link.
Rationale: Capabilities are demonstrated here, not configured during setup.
### 11 · Externally bound task
Purpose: A normal Paperclip task with explicit publication and detach controls.
1. The task shows its external source.
2. External actors remain attributed.
3. Publishing back to the provider is explicit for human comments.
4. The agent remains locked until detach.
Rationale: External work stays in the ordinary governed task experience.
### 12 · Agent Channels
Purpose: See every provider identity representing this agent.
1. Channel identities are summarized per provider.
2. Health and recent tasks remain visible.
3. Connections open in Connectors.
4. Connect a channel preselects this agent.
Rationale: Agent detail summarizes endpoints while Connectors manages them.

View File

@ -0,0 +1,558 @@
# Paperclip Chat Adapters UI Surfaces — v7
> Historical snapshot. The current permission, Access, and Conversations design is [v8](./2026-09-04-chat-adapters-ui-surfaces-v8.md). Generated wireframes are in the [Git archive](./wireframes-archive.md). Managed-install and helper-first concepts below are not shipped requirements.
Date: 2026-09-04
Paperclip base: `7b094724e65c04949706df638d497afb02c84b62`
Historical review viewer: [Git archive](./wireframes-archive.md).
Archived wireframes: [v7 SVG snapshot](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters/wireframes-v7) ([archive and regeneration notes](./wireframes-archive.md))
## Product decision
Overview is removed. Activated connectors open on Settings and expose only four management tabs: Settings, Access, Conversations, and Activity. Settings contains only destination reach that a user can plausibly change.
- **Channel activation:** A root mention creates a provider-native thread and one Paperclip task on Slack and Teams. Replies in that thread continue the same task without another mention.
- **Existing provider thread:** The first mention inside an unbound Slack or Teams thread binds that existing thread to one new Paperclip task. Earlier messages are not imported automatically.
- **Direct messages:** One open task is active in a DM. A completed task stays closed; the next message starts a new task. New task or /new starts another task explicitly.
- **GitHub conversations:** A mention binds the addressed issue, pull-request conversation, or inline review thread to one Paperclip task.
- **Telegram conversations:** DMs and ordinary groups use one active task. A forum topic has one stable topic-to-task binding.
- **Delivery:** Paperclip chooses direct verified webhooks when reachable and the instance relay when private. This is deployment configuration, not an endpoint preference.
- **Credentials and drift:** Invalid credentials, revoked installs, missing membership, or permission drift appear in Activity with a reconnect or repair action. They are not ordinary settings.
## Settings inventory
- Slack: allowed channels and an Allow direct messages toggle.
- GitHub: allowed repositories only.
- Microsoft Teams: allowed channels, Allow direct messages, and Allow group chats.
- Telegram: allowed groups/topics and an Allow direct messages toggle.
## Screen inventory
| ID | Group | Surface | Title | Desktop | Mobile |
| --- | --------------- | ------------------------ | -------------------------------------- | --------- | -------- |
| 01 | Start | Shared | Connectors | 1280×800 | 375×812 |
| 02 | Start | Shared | Choose how to connect | 1280×800 | 375×812 |
| 03 | Start | Shared | Which agent do you want to chat with? | 1280×800 | 375×812 |
| 13 | Slack | Setup | Add Maya to Slack | 1280×800 | 375×812 |
| 42 | Slack | Custom setup | Create and install the Slack app | 1280×800 | 375×1064 |
| 43 | Slack | Custom setup | Connect the Slack app | 1280×800 | 375×1176 |
| 41 | Slack | Setup | Try Maya in Slack | 1280×800 | 375×944 |
| 14 | Slack | Settings | Slack settings | 1280×800 | 375×936 |
| 26 | Slack | Access | Slack access | 1280×1256 | 375×1592 |
| 27 | Slack | Conversations | Slack conversations | 1280×1160 | 375×1600 |
| 28 | Slack | Activity | Slack activity | 1280×1200 | 375×1640 |
| 15 | Slack | Conversation walkthrough | How Slack conversations work | 1280×960 | 375×1320 |
| 16 | GitHub | Setup | Create Maya in GitHub | 1280×800 | 375×952 |
| 45 | GitHub | Setup | Choose GitHub repositories | 1280×800 | 375×1000 |
| 46 | GitHub | Setup | Try Maya in GitHub | 1280×800 | 375×1000 |
| 47 | GitHub | Custom setup | Connect an existing GitHub App | 1280×960 | 375×1392 |
| 17 | GitHub | Settings | GitHub settings | 1280×800 | 375×812 |
| 30 | GitHub | Access | GitHub access | 1280×1256 | 375×1592 |
| 31 | GitHub | Conversations | GitHub conversations | 1280×1160 | 375×1600 |
| 32 | GitHub | Activity | GitHub activity | 1280×1200 | 375×1640 |
| 18 | GitHub | Conversation walkthrough | How GitHub conversations work | 1280×960 | 375×1320 |
| 19 | Microsoft Teams | Setup | Create Maya for Microsoft Teams | 1280×800 | 375×1080 |
| 49 | Microsoft Teams | Setup | Install Maya in Microsoft Teams | 1280×800 | 375×888 |
| 50 | Microsoft Teams | Setup | Try Maya in Microsoft Teams | 1280×800 | 375×1000 |
| 48 | Microsoft Teams | Custom setup | Set up Microsoft manually | 1280×1064 | 375×1496 |
| 20 | Microsoft Teams | Settings | Microsoft Teams settings | 1280×800 | 375×968 |
| 34 | Microsoft Teams | Access | Microsoft Teams access | 1280×1256 | 375×1592 |
| 35 | Microsoft Teams | Conversations | Microsoft Teams conversations | 1280×1160 | 375×1600 |
| 36 | Microsoft Teams | Activity | Microsoft Teams activity | 1280×1200 | 375×1640 |
| 21 | Microsoft Teams | Conversation walkthrough | How Microsoft Teams conversations work | 1280×960 | 375×1320 |
| 22 | Telegram | Setup | Create Maya in Telegram | 1280×800 | 375×1128 |
| 51 | Telegram | Setup | Try Maya in Telegram | 1280×800 | 375×832 |
| 23 | Telegram | Settings | Telegram settings | 1280×800 | 375×952 |
| 38 | Telegram | Access | Telegram access | 1280×1256 | 375×1592 |
| 39 | Telegram | Conversations | Telegram conversations | 1280×1160 | 375×1600 |
| 40 | Telegram | Activity | Telegram activity | 1280×1200 | 375×1640 |
| 24 | Telegram | Conversation walkthrough | How Telegram conversations work | 1280×960 | 375×1320 |
| 11 | Paperclip | Task | Externally bound task | 1280×800 | 375×812 |
| 12 | Paperclip | Agent | Agent Channels | 1280×800 | 375×812 |
## Annotation and action notes
### 01 · Connectors
Purpose: Connect tools and places where people talk to agents.
1. The existing Apps catalog remains the entry point.
2. Filters separate chat and tool methods.
3. Each connector row has one Connect action.
4. Connection state remains visible in the catalog.
Rationale: The current Connectors surface remains canonical.
### 02 · Choose how to connect
Purpose: Shown for every connector that supports both chat and tool methods.
1. The existing connection wizard shell and selected provider are reused.
2. Chat with an agent is the incoming-conversation path.
3. Use this connection as an agent tool is the outbound tool/credential path.
4. Single-purpose providers skip the choice.
Rationale: The registry drives the same direction choice for every dual-surface connector.
### 03 · Which agent do you want to chat with?
Purpose: Choose the one agent represented by this connection.
1. The existing agent selector is reused.
2. Only active agents can be selected.
3. One selection is required.
4. Continue begins provider setup.
Rationale: This is the only shared Paperclip-specific setup decision.
### 13 · Add Maya to Slack
Purpose: Install Maya in your Slack workspace.
1. The step rail is the only repeated setup context; the selected agent is not restated in the page body.
2. The page contains only the installation action and the necessary customer-owned-App fallback.
Actions:
- **Add Maya to Slack:** Opens Slack's Add to Slack flow. The operator chooses a workspace and approves the installation; Slack then returns to the Try Maya step.
- **Set up a custom Slack app:** Opens the customer-owned Slack App instructions for self-hosted deployments or organizations that cannot use Add to Slack.
Rationale: Nothing else on this page requires operator attention.
### 42 · Create and install the Slack app
Purpose: Paperclip prepared a Slack App Manifest for Maya.
1. Every line is an action the operator must complete in Slack.
2. The manifest removes manual scope, event, callback, command, and interactivity configuration.
3. The page advances only after the operator confirms the app was installed.
Actions:
- **Open Slack app setup:** Opens Slack's official app-from-manifest URL with Paperclip's generated manifest encoded in the link.
- **Continue after installing:** Advances to the two credential fields after the operator has installed the new app in Slack.
Rationale: The custom path gives exact provider instructions without exposing Paperclip's automatic configuration.
### 43 · Connect the Slack app
Purpose: Copy two values from the Slack app settings.
1. The only help text tells the operator exactly where to find each required value.
2. Only the two unavoidable Slack credentials are requested.
3. Connecting verifies the values instead of showing a separate verification report.
Actions:
- **Connect Slack app:** Stores both values write-only and verifies the Slack bot identity and required scopes before continuing.
- **Back:** Returns to the Slack creation instructions without saving partially entered values.
Rationale: A customer-owned Slack App cannot return these values to Paperclip, so both fields are necessary.
### 41 · Try Maya in Slack
Purpose: Start one task and reply to it once.
1. The body is only the three actions needed to test the real Slack interaction.
2. The instructions teach the root-mention-to-thread Paperclip task boundary.
3. There is one action: open Slack and perform the test.
Actions:
- **Open Slack:** Opens the installed workspace while Paperclip waits for the root mention and thread reply to complete setup.
Rationale: Installation health and automatic verification do not belong on an instruction screen.
### 14 · Slack settings
Purpose: Choose where people can start conversations with Maya.
1. The connector starts on Settings; the read-only Overview tab is removed.
2. Workspace appears only as context on each allowed channel; allowed channels are the only Slack resource choice.
3. Direct messages are one explicit on/off choice.
4. Save persists only reach changes; thread boundaries, delivery, credentials, drift, and capabilities are absent.
Rationale: Only destination reach remains configurable; all conversation and delivery behavior is a product default.
### 26 · Slack access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Identity and authority remain independently manageable.
### 27 · Slack conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Operators can inspect and detach durable bindings.
### 28 · Slack activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 15 · How Slack conversations work
Purpose: The fixed provider-native interaction and fallback model.
1. Ari starts in a Slack channel with a root @maya mention; unrelated root messages do not start work.
2. Maya acknowledges inside a Slack thread, making the thread—not the channel—the visible conversation boundary.
3. Paperclip creates exactly one assigned issue and shows its Slack source, external participant, and publication state.
4. Ari continues by replying in the same thread without another mention; files and actions remain in that context.
5. Maya's safe progress and final answer publish in the thread; failures offer retry or a Paperclip link.
Rationale: The walkthrough explains automatic behavior without turning it into configuration.
### 16 · Create Maya in GitHub
Purpose: Create a dedicated GitHub App from Paperclip's prepared manifest.
1. Only the two choices GitHub presents during App creation are described.
2. The normal action uses the GitHub App Manifest handoff; credentials never pass through the operator.
3. The existing-App branch remains available without cluttering the default path.
Actions:
- **Create in GitHub:** Posts Paperclip's App Manifest to GitHub. GitHub returns to Paperclip after creation, and Paperclip stores the returned App credentials.
- **Use an existing GitHub App:** Opens the advanced path for an App the organization already owns.
Rationale: The manifest already fixes permissions, events, and webhook configuration.
### 45 · Choose GitHub repositories
Purpose: Install Maya where people should be able to mention it.
1. The screen contains only GitHub's installation decisions.
2. Repository scope stays in GitHub's native approval UI.
3. One button begins the complete provider-owned installation step.
Actions:
- **Install in GitHub:** Opens GitHub's App installation page and returns the installation and selected repository IDs to Paperclip.
Rationale: There is no Paperclip form to duplicate GitHub's repository picker.
### 46 · Try Maya in GitHub
Purpose: Start one task in an installed repository.
1. The body is only the native GitHub test sequence.
2. The instructions explain that GitHub's existing issue or pull request is the task boundary.
3. There is one action: open GitHub and perform the test.
Actions:
- **Open GitHub:** Opens an installed repository while Paperclip waits for the first signed mention to complete setup.
Rationale: A real mention proves the App installation without a separate verification screen.
### 47 · Connect an existing GitHub App
Purpose: Update the App in GitHub, then provide its identity credentials.
1. The copy control provides the exact values the operator must paste into GitHub.
2. The instructions list every provider change required for an existing App.
3. Only App ID and private key return to Paperclip; the generated webhook secret is already stored.
4. Verification happens as part of Connect rather than on another screen.
Actions:
- **Copy Paperclip webhook settings:** Copies the endpoint URL and generated webhook secret needed in the existing GitHub App settings.
- **Connect and verify:** Stores the PEM file write-only, authenticates as the App, and verifies webhook, events, and least-privilege permissions.
- **Back:** Returns to the credential-free App Manifest path.
Rationale: Existing Apps lack the manifest callback, so this advanced page contains the complete minimum manual configuration.
### 17 · GitHub settings
Purpose: Choose the repositories where people can mention Maya.
1. The connector starts on Settings; the read-only Overview tab is removed.
2. The account and App installation are fixed; repository reach is the only normal GitHub chat setting.
3. Save persists the repository allowlist; private-key or installation repair begins from Activity only when needed.
Rationale: Only destination reach remains configurable; all conversation and delivery behavior is a product default.
### 30 · GitHub access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Identity and authority remain independently manageable.
### 31 · GitHub conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Operators can inspect and detach durable bindings.
### 32 · GitHub activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 18 · How GitHub conversations work
Purpose: The fixed provider-native interaction and fallback model.
1. Ari mentions the bot in an existing GitHub issue, PR conversation, or inline review thread.
2. Maya acknowledges with a reaction and one GitHub-Flavored Markdown comment rather than opening another thread.
3. Paperclip binds that exact GitHub object or review thread to one assigned issue; PR conversation and inline review stay distinct.
4. Later comments continue the same issue, while bot-authored comments and duplicate deliveries are ignored.
5. Progress edits the existing comment; files and governed actions use authenticated Paperclip links.
Rationale: The walkthrough explains automatic behavior without turning it into configuration.
### 19 · Create Maya for Microsoft Teams
Purpose: Run one command to register Maya with Microsoft.
1. The generated command is the only normal-path configuration artifact.
2. Both instructions are actions the operator performs locally or in Microsoft's login.
3. The manual path is available without exposing Azure choices on the default screen.
Actions:
- **Copy setup command:** Copies a one-time Paperclip command that invokes Microsoft's Teams Developer CLI, signs the operator in, creates the Teams App and bot registration, and sends the resulting identity to this setup draft.
- **Set up Microsoft manually:** Opens the Azure/Teams manual fallback for tenants that cannot run the guided command.
Rationale: The helper collapses Microsoft registration into one attended command while Microsoft remains the authority for sign-in and tenant policy.
### 49 · Install Maya in Microsoft Teams
Purpose: Open the Microsoft install page and add the app.
1. The install link replaces package download and upload on the normal path.
2. The body contains only the two actions performed in Microsoft Teams.
3. Tenant approval is handled by Microsoft's install experience, not another Paperclip choice.
Actions:
- **Install Maya in Teams:** Opens the install link returned by Microsoft. Tenant policy may route the same request to an administrator for approval.
Rationale: Microsoft's CLI returns an install link, so normal setup should use it directly.
### 50 · Try Maya in Microsoft Teams
Purpose: Start one task in a channel post.
1. The body is only the Teams channel test sequence.
2. The instructions teach the channel-post-and-replies task boundary.
3. There is one action: open Teams and perform the test.
Actions:
- **Open Microsoft Teams:** Opens Teams while Paperclip waits for the first authenticated mention and reply to complete setup.
Rationale: The final provider event is the verification; no installation report is shown first.
### 48 · Set up Microsoft manually
Purpose: Create the bot in Microsoft, then paste the three identity values.
1. The copy control provides the one Paperclip value required by Microsoft.
2. Every instruction is a portal operation the tenant administrator must perform.
3. The three fields are the minimum identity values Paperclip needs to send as the bot.
4. Connect verifies the identity and produces the same install step as the default flow.
Actions:
- **Copy Paperclip endpoint:** Copies the public messaging endpoint that must be entered on the Azure Bot resource.
- **Connect and create Teams app:** Stores the client secret write-only, verifies Microsoft bot authentication, and creates the installable Teams app and install link.
- **Back:** Returns to the guided one-command setup.
Rationale: The manual fallback is longer because Microsoft has no manifest callback equivalent; no optional Azure choices are exposed.
### 20 · Microsoft Teams settings
Purpose: Choose where people can start conversations with Maya.
1. The connector starts on Settings; the read-only Overview tab is removed.
2. Tenant and bot identity are fixed; the tenant appears only as context on allowed Teams channels.
3. Personal and group chats are independent reach toggles.
4. Save persists only reach changes; post boundaries, consent, delivery, credentials, and drift are absent.
Rationale: Only destination reach remains configurable; all conversation and delivery behavior is a product default.
### 34 · Microsoft Teams access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Identity and authority remain independently manageable.
### 35 · Microsoft Teams conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Operators can inspect and detach durable bindings.
### 36 · Microsoft Teams activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 21 · How Microsoft Teams conversations work
Purpose: The fixed provider-native interaction and fallback model.
1. Ari mentions Maya in a new Teams channel post; that post and its replies are the native thread.
2. Maya acknowledges under the post. If the installed permissions cannot deliver unmentioned replies, the bot says to mention Maya again.
3. Paperclip creates one assigned issue and records tenant, team/channel, thread, and external participant attribution.
4. Replies, files, and Adaptive Card or task-module actions continue only when current Teams delivery and Paperclip permissions allow.
5. DMs may stream natively; channel and group output buffers or edits, with targeted-message, DM, or text-link fallback.
Rationale: The walkthrough explains automatic behavior without turning it into configuration.
### 22 · Create Maya in Telegram
Purpose: Create the bot with BotFather and paste its token.
1. The page contains the exact three BotFather actions.
2. The bot token is Telegram's only unavoidable setup input.
3. The two buttons let the operator leave for BotFather and connect after returning.
Actions:
- **Open BotFather:** Opens Telegram's verified BotFather conversation so the operator can run /newbot.
- **Connect bot:** Stores the token write-only, verifies the bot with getMe, and continues to the test step.
Rationale: Webhook, polling, commands, and identity checks are automatic and therefore absent.
### 51 · Try Maya in Telegram
Purpose: Send the bot its first message.
1. The minimum proof is one private message; group and forum reach can be added after connection.
2. The body contains only the two Telegram actions required for the test.
3. There is one action: open the bot and send the message.
Actions:
- **Open Maya in Telegram:** Opens the bot's t.me link while Paperclip waits for the first verified private message to complete setup.
Rationale: A private chat is Telegram's shortest path from BotFather token to a working Paperclip conversation.
### 23 · Telegram settings
Purpose: Choose where people can start conversations with Maya.
1. The connector starts on Settings; the read-only Overview tab is removed.
2. Allowed groups and forum topics are the Telegram resource choice.
3. Direct messages are one explicit on/off choice.
4. Save persists only reach changes; task boundaries, privacy, delivery, token rotation, and health are absent.
Rationale: Only destination reach remains configurable; all conversation and delivery behavior is a product default.
### 38 · Telegram access
Purpose: Identity links, sponsored guests, and effective authority.
1. The endpoint sponsor supplies the maximum authority available to unlinked external people.
2. Linked provider identities act as their current Paperclip users and retain ordinary permission checks.
3. Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.
4. Provider identity and scope details make effective authority explainable and auditable.
Rationale: Identity and authority remain independently manageable.
### 39 · Telegram conversations
Purpose: Native conversation-to-Paperclip task bindings.
1. Each row names the provider-native conversation boundary and its single Paperclip issue.
2. Participants, assigned agent, state, and last activity make live bindings scannable.
3. Open in provider and Open task take an operator to either side of the binding.
4. Detach preserves history and publication records; a later activation creates or claims a new binding.
Rationale: Operators can inspect and detach durable bindings.
### 40 · Telegram activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 24 · How Telegram conversations work
Purpose: The fixed provider-native interaction and fallback model.
1. In a DM, Ari's first message creates the active issue; New task or /new deliberately starts another.
2. In a privacy-on group, @maya starts work and replying to Maya continues; unrelated group traffic is not consumed.
3. A forum topic can bind one issue through message_thread_id when the bot is present and allowed.
4. Paperclip shows the active issue and makes the linear-chat boundary explicit instead of implying a Slack-style native thread.
5. Maya uses throttled post/edit and inline buttons; unsupported or governed actions return text or DM with a Paperclip link.
Rationale: The walkthrough explains automatic behavior without turning it into configuration.
### 11 · Externally bound task
Purpose: A normal Paperclip task with explicit publication and detach controls.
1. The task shows its external source.
2. External actors remain attributed.
3. Publishing back to the provider is explicit for human comments.
4. The agent remains locked until detach.
Rationale: External work stays in the ordinary governed task experience.
### 12 · Agent Channels
Purpose: See every provider identity representing this agent.
1. Channel identities are summarized per provider.
2. Health and recent tasks remain visible.
3. Connections open in Connectors.
4. Connect a channel preselects this agent.
Rationale: Agent detail summarizes endpoints while Connectors manages them.

View File

@ -0,0 +1,444 @@
# Paperclip Chat Adapters UI Surfaces — v8
Date: 2026-09-04
Original planning base: `d593463ab6394cd356bf27448ea28bad8cccf4ec`; release qualification records the exact tested revision separately.
Historical viewer and wireframes: [Git archive](./wireframes-archive.md); generated images are excluded from the PR.
## Permission model
- **Provider availability:** Slack, Teams, and Telegram decide where the bot is installed or invited. GitHub decides which repositories belong to the App installation.
- **Paperclip enablement:** Paperclip responds only in provider resources that a Paperclip administrator has enabled for this connection. Invitation or installation alone is not permission to create a task.
- **Effective reach:** A message is eligible only when the provider delivers it, its resource is enabled in Paperclip, the connection is active, and the sender has authority for the requested action.
- **Safe default:** The destination used for the successful setup test becomes the first enabled resource. Resources discovered later start disabled.
## Access tab
**Settings answers where the bot may work. Access answers who an external sender represents and what Paperclip authority applies.** A linked external identity acts as its mapped Paperclip user and is checked against current permissions on every action. An unlinked identity may be allowed under the fixed restricted profile: it can converse within enabled resources and attach safe files, but it cannot approve, change budgets, hire, manage permissions or connections, or reassign agents. The connection owner remains an internal audit and authority ceiling; it is not ordinary UI configuration.
## Conversations tab
Each provider has one plain list. Every row contains the external conversation, Paperclip task, current state, an Open-provider link, and Open task. There is no separate binding-management section or conversation-boundary explainer. If provider access disappears, the row becomes unavailable while its history remains inspectable.
The former "How conversations work" screens are removed. Provider-native activation and reply behavior remains implementation documentation, not a standalone product page.
## Five-provider implementation addendum — 2026-09-06
Discord now uses the same product shell even though the v8 generated wireframe inventory below predates that implementation. Its current UI contract is:
- **Setup:** enter a customer-owned Application ID, Server ID, and write-only bot token; inspect the server-pinned `bot`-scope install URL; connect; then enable and test one channel. No webhook URL, interactions key, slash command, managed provisioning, or delivery-mode choice appears.
- **Settings:** show provider identity and only the plausible direct-message reach switch. Provider capabilities are automatic.
- **Access:** list Discord text channels available to the installed bot, with Paperclip enablement as an independent narrower allowlist, plus linked numeric Discord-user identities and the unlinked-participation policy.
- **Conversations:** show the Discord thread or DM generation, Paperclip task, current state, **Open Discord**, and **Open task**. There are no detach or rebinding controls.
- **Activity:** show Gateway/runtime health, durable deliveries/publications, redacted provider failures, and contextual reconnect/rotation actions.
The linked v8 SVGs remain a four-provider visual-design artifact; they are not evidence that Discord is absent from the product or that Discord has passed live qualification. The live browser runbook and dated qualification result are the current five-provider acceptance sources.
## Screen inventory
| ID | Group | Surface | Title | Desktop | Mobile |
|---|---|---|---|---|---|
| 01 | Start | Shared | Connectors | 1280×800 | 375×812 |
| 02 | Start | Shared | Choose how to connect | 1280×800 | 375×812 |
| 03 | Start | Shared | Which agent do you want to chat with? | 1280×800 | 375×812 |
| 13 | Slack | Setup | Connect a Slack app | 1280×800 | 375×812 |
| 41 | Slack | Setup | Try Maya in Slack | 1280×800 | 375×944 |
| 14 | Slack | Settings | Slack settings | 1280×984 | 375×1072 |
| 26 | Slack | Access | Slack access | 1280×880 | 375×920 |
| 27 | Slack | Conversations | Slack conversations | 1280×800 | 375×916 |
| 28 | Slack | Activity | Slack activity | 1280×1200 | 375×1640 |
| 16 | GitHub | Setup | Create or connect a GitHub App | 1280×920 | 375×1312 |
| 46 | GitHub | Setup | Try Maya in GitHub | 1280×800 | 375×812 |
| 17 | GitHub | Settings | GitHub settings | 1280×816 | 375×896 |
| 30 | GitHub | Access | GitHub access | 1280×880 | 375×920 |
| 31 | GitHub | Conversations | GitHub conversations | 1280×800 | 375×916 |
| 32 | GitHub | Activity | GitHub activity | 1280×1200 | 375×1640 |
| 19 | Microsoft Teams | Setup | Create Maya for Microsoft Teams | 1280×800 | 375×1080 |
| 49 | Microsoft Teams | Setup | Install Maya in Microsoft Teams | 1280×800 | 375×888 |
| 50 | Microsoft Teams | Setup | Try Maya in Microsoft Teams | 1280×800 | 375×1000 |
| 48 | Microsoft Teams | Setup | Microsoft provider setup details | 1280×1064 | 375×1496 |
| 20 | Microsoft Teams | Settings | Microsoft Teams settings | 1280×1064 | 375×1176 |
| 34 | Microsoft Teams | Access | Microsoft Teams access | 1280×880 | 375×920 |
| 35 | Microsoft Teams | Conversations | Microsoft Teams conversations | 1280×800 | 375×916 |
| 36 | Microsoft Teams | Activity | Microsoft Teams activity | 1280×1200 | 375×1640 |
| 22 | Telegram | Setup | Create Maya in Telegram | 1280×800 | 375×1128 |
| 51 | Telegram | Setup | Try Maya in Telegram | 1280×800 | 375×832 |
| 23 | Telegram | Settings | Telegram settings | 1280×984 | 375×1072 |
| 38 | Telegram | Access | Telegram access | 1280×880 | 375×920 |
| 39 | Telegram | Conversations | Telegram conversations | 1280×800 | 375×916 |
| 40 | Telegram | Activity | Telegram activity | 1280×1200 | 375×1640 |
| 11 | Paperclip | Task | Externally connected task | 1280×800 | 375×812 |
| 12 | Paperclip | Agent | Agent Channels | 1280×800 | 375×812 |
## Annotation and action notes
### 01 · Connectors
Purpose: Connect tools and places where people talk to agents.
1. The existing Apps catalog remains the entry point.
2. Filters separate chat and tool methods.
3. Each connector row has one Connect action.
4. Connection state remains visible in the catalog.
Rationale: The current Connectors surface remains canonical.
### 02 · Choose how to connect
Purpose: Shown for every connector that supports both chat and tool methods.
1. The existing connection wizard shell and selected provider are reused.
2. Chat with an agent is the incoming-conversation path.
3. Use this connection as an agent tool is the outbound tool/credential path.
4. Single-purpose providers skip the choice.
Rationale: The registry drives the same direction choice for every dual-surface connector.
### 03 · Which agent do you want to chat with?
Purpose: Choose the one agent represented by this connection.
1. The existing agent selector is reused.
2. Only active agents can be selected.
3. One selection is required.
4. Continue begins provider setup.
Rationale: This is the only shared Paperclip-specific setup decision.
### 13 · Connect a Slack app
Purpose: Bring your own Slack app using Paperclip's prepared manifest.
1. The prepared manifest and exact provider locations make the customer-owned App the complete required path.
2. Only the Bot User OAuth Token and Signing Secret are entered, and both remain write-only.
3. Managed Add to Slack is not shipped; a later convenience cannot gate this path or release.
Actions:
- **Connect Slack app:** Stores the two write-only credentials and verifies the Slack bot identity and required scopes.
- **Open Slack app settings:** Opens Slack's app-management page where the operator creates and installs the customer-owned App.
Rationale: Bring-your-own credentials are the complete shipped path; no managed installation is required or currently shown.
### 41 · Try Maya in Slack
Purpose: Start one task and reply to it once.
1. The body is only the three actions needed to test the real Slack interaction.
2. The instructions teach the root-mention-to-thread Paperclip task boundary.
3. There is one action: open Slack and perform the test.
Actions:
- **Open Slack:** Opens the installed workspace while Paperclip waits for the root mention and thread reply to complete setup.
Rationale: Installation health and automatic verification do not belong on an instruction screen.
### 14 · Slack settings
Purpose: Enable the Slack channels where Maya may create and continue tasks.
1. Only provider-available destinations appear here.
2. Each toggle is Paperclip's independent allow or deny decision.
3. The provider action changes availability; newly discovered destinations remain disabled.
4. Private-conversation reach is an explicit Paperclip choice.
Rationale: Provider membership is the ceiling; Paperclip enablement is the narrower enforcement boundary.
### 26 · Slack access
Purpose: Decide how people are identified when they message Maya.
1. The only guest-policy choice is whether unlinked people may participate.
2. The restricted profile permits task conversation but never Paperclip governance.
3. Linked accounts map a stable Slack workspace ID + user ID to a Paperclip user and can be revoked.
Rationale: Settings controls where the bot works; Access controls who external people represent and which authority model applies.
### 27 · Slack conversations
Purpose: Conversations created through this connection.
1. The active row pairs one Slack conversation with its task, state, Open Slack, and Open task links.
2. The waiting row keeps the same compact fields and actions.
3. The completed row remains available as history with the same two links.
Rationale: Conversations is a plain cross-linking list, not a binding-management surface.
### 28 · Slack activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 16 · Create or connect a GitHub App
Purpose: Bring your own dedicated GitHub App and verify it with Paperclip.
1. The customer-owned App path is the complete shipped setup; no managed App Manifest exchange is required.
2. Paperclip generates the webhook secret and never returns it from normal endpoint reads.
3. Grant Metadata read, Issues and Pull requests read/write, plus Issue comment and Pull request review comment events; installation lifecycle events are automatic.
4. GitHub installation scope and Paperclip repository enablement remain independent reach controls.
Actions:
- **Generate webhook secret:** Creates and stores the webhook secret, then exposes its one-time copy value.
- **Open new GitHub App form:** Opens GitHub App registration; GitHub remains the authority for App ownership and repository installation.
- **Connect and verify:** Authenticates with the App ID and private key, verifies the immutable App identity, required permissions and events, installation, and signed webhook ping.
Rationale: Bring-your-own App credentials are sufficient to ship and preserve one provider bot identity per Paperclip agent.
### 46 · Try Maya in GitHub
Purpose: Start one task in an installed repository.
1. The test uses the real GitHub issue or pull-request conversation boundary.
2. The first addressed setup repository becomes enabled; other discovered repositories remain disabled.
3. One external conversation maps to one Paperclip task.
Actions:
- **Open GitHub:** Opens an installed repository while Paperclip waits for the first signed mention and follow-up to complete setup.
Rationale: A signed provider round trip proves installation, reach, identity, and conversation continuity.
### 17 · GitHub settings
Purpose: Enable the repositories where Maya may respond to mentions.
1. Only provider-available destinations appear here.
2. Each toggle is Paperclip's independent allow or deny decision.
3. The provider action changes availability; newly discovered destinations remain disabled.
Rationale: Provider membership is the ceiling; Paperclip enablement is the narrower enforcement boundary.
### 30 · GitHub access
Purpose: Decide how people are identified when they mention Maya.
1. The only guest-policy choice is whether unlinked people may participate.
2. The restricted profile permits task conversation but never Paperclip governance.
3. Linked accounts map a stable GitHub host + numeric user ID to a Paperclip user and can be revoked.
Rationale: Settings controls where the bot works; Access controls who external people represent and which authority model applies.
### 31 · GitHub conversations
Purpose: Conversations created through this connection.
1. The active row pairs one GitHub conversation with its task, state, Open GitHub, and Open task links.
2. The waiting row keeps the same compact fields and actions.
3. The completed row remains available as history with the same two links.
Rationale: Conversations is a plain cross-linking list, not a binding-management surface.
### 32 · GitHub activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 19 · Create Maya for Microsoft Teams
Purpose: Register a customer-owned Entra App and Azure Bot.
1. Paperclip provides the exact public messaging endpoint.
2. The operator creates the single-tenant Entra App, Azure Bot, and Teams app in Microsoft.
3. No provisioning helper is shipped or required for the customer-owned path.
Actions:
- **Copy messaging endpoint:** Copies the public callback for Azure Bot configuration.
- **Open Microsoft setup:** Opens Microsoft's provider-owned registration surfaces.
- **Connect and verify:** Stores the client secret write-only and verifies the tenant and application identity.
Rationale: Bring-your-own credentials are the required portable setup path.
### 49 · Install Maya in Microsoft Teams
Purpose: Publish or upload the customer-owned app, then add it in Teams.
1. Microsoft owns app creation, packaging, publication, approval, and installation.
2. Paperclip does not generate a complete Teams package or promise an install link.
3. Tenant approval remains in Microsoft's install experience.
Actions:
- **Open Teams Developer Portal:** Opens the provider-owned app surface; tenant policy may require administrator approval.
Rationale: Customer-owned registration is required; Microsoft owns the app artifact and installation.
### 50 · Try Maya in Microsoft Teams
Purpose: Start one task in a channel post.
1. The body is only the Teams channel test sequence.
2. The instructions teach the channel-post-and-replies task boundary.
3. There is one action: open Teams and perform the test.
Actions:
- **Open Microsoft Teams:** Opens Teams while Paperclip waits for the authenticated mention and reply.
Rationale: The final provider event is the verification.
### 48 · Microsoft provider setup details
Purpose: Create the customer-owned bot and app, then paste the three identity values.
1. The endpoint is the one Paperclip-specific value required by Microsoft.
2. Every instruction is a provider portal operation.
3. The three identity fields are the minimum credentials Paperclip needs.
4. Connect does not generate a Teams package or install link.
Actions:
- **Copy Paperclip endpoint:** Copies the public messaging endpoint for the Azure Bot resource.
- **Connect and verify:** Stores the client secret write-only and verifies Microsoft bot authentication.
- **Back:** Returns to the primary customer-owned credential setup.
Rationale: This is reference detail for the complete required customer-owned path.
### 20 · Microsoft Teams settings
Purpose: Enable the Teams channels where Maya may create and continue tasks.
1. Only provider-available destinations appear here.
2. Each toggle is Paperclip's independent allow or deny decision.
3. The provider action changes availability; newly discovered destinations remain disabled.
4. Private-conversation reach is an explicit Paperclip choice.
Rationale: Provider membership is the ceiling; Paperclip enablement is the narrower enforcement boundary.
### 34 · Microsoft Teams access
Purpose: Decide how people are identified when they message Maya.
1. The only guest-policy choice is whether unlinked people may participate.
2. The restricted profile permits task conversation but never Paperclip governance.
3. Linked accounts map a stable Microsoft tenant ID + Entra object ID to a Paperclip user and can be revoked.
Rationale: Settings controls where the bot works; Access controls who external people represent and which authority model applies.
### 35 · Microsoft Teams conversations
Purpose: Conversations created through this connection.
1. The active row pairs one Microsoft Teams conversation with its task, state, Open Teams, and Open task links.
2. The waiting row keeps the same compact fields and actions.
3. The completed row remains available as history with the same two links.
Rationale: Conversations is a plain cross-linking list, not a binding-management surface.
### 36 · Microsoft Teams activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 22 · Create Maya in Telegram
Purpose: Create the bot with BotFather and paste its token.
1. The page contains the exact three BotFather actions.
2. The bot token is Telegram's only unavoidable setup input.
3. The two buttons let the operator leave for BotFather and connect after returning.
Actions:
- **Open BotFather:** Opens Telegram's verified BotFather conversation so the operator can run /newbot.
- **Connect bot:** Stores the token write-only, verifies the bot with getMe, and continues to the test step.
Rationale: Webhook, polling, commands, and identity checks are automatic and therefore absent.
### 51 · Try Maya in Telegram
Purpose: Send the bot its first message.
1. The minimum proof is one private message; group and forum reach can be added after connection.
2. The body contains only the two Telegram actions required for the test.
3. There is one action: open the bot and send the message.
Actions:
- **Open Maya in Telegram:** Opens the bot's t.me link while Paperclip waits for the first verified private message to complete setup.
Rationale: A private chat is Telegram's shortest path from BotFather token to a working Paperclip conversation.
### 23 · Telegram settings
Purpose: Enable the Telegram chats and topics where Maya may create and continue tasks.
1. Only provider-available destinations appear here.
2. Each toggle is Paperclip's independent allow or deny decision.
3. The provider action changes availability; newly discovered destinations remain disabled.
4. Private-conversation reach is an explicit Paperclip choice.
Rationale: Provider membership is the ceiling; Paperclip enablement is the narrower enforcement boundary.
### 38 · Telegram access
Purpose: Decide how people are identified when they message Maya.
1. The only guest-policy choice is whether unlinked people may participate.
2. The restricted profile permits task conversation but never Paperclip governance.
3. Linked accounts map a stable Telegram bot ID + numeric user ID to a Paperclip user and can be revoked.
Rationale: Settings controls where the bot works; Access controls who external people represent and which authority model applies.
### 39 · Telegram conversations
Purpose: Conversations created through this connection.
1. The active row pairs one Telegram conversation with its task, state, Open Telegram, and Open task links.
2. The waiting row keeps the same compact fields and actions.
3. The completed row remains available as history with the same two links.
Rationale: Conversations is a plain cross-linking list, not a binding-management surface.
### 40 · Telegram activity
Purpose: Health, deliveries, publications, and repair actions.
1. Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.
2. Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.
3. Operators can inspect redacted errors and replay only safe, authorized failed deliveries.
4. Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible.
Rationale: Diagnostics and conditional repairs live here instead of Settings.
### 11 · Externally connected task
Purpose: A normal Paperclip task connected to its provider conversation.
1. The task shows its external source and provider link.
2. External actors remain attributed.
3. Eligible agent output shows publication status.
4. Board comments remain internal unless Send to channel is selected.
Rationale: The agent assignment stays fixed for the lifetime of the external task; a different agent requires a new connection.
### 12 · Agent Channels
Purpose: See every provider identity representing this agent.
1. Channel identities are summarized per provider.
2. Health and recent tasks remain visible.
3. Connections open in Connectors.
4. Connect a channel preselects this agent.
Rationale: Agent detail summarizes endpoints while Connectors manages them.

View File

@ -0,0 +1,312 @@
# GitHub live qualification result — 2026-09-05
For the September 7 private-attachment limitation and live outbound task-notice
check, see [media qualification](2026-09-07-media-live-qualification.md).
For September 89 native Luna conversations, private-file omission/pasted-text
proof and deployment, use the
[current qualification ledger](2026-09-08-chat-queue-and-webhook-repair.md).
Historical rows below keep their original scopes. Server 70's new task-link →
correct-task upload journey and remaining provider permutations are not yet
qualified live.
> **Status: current App connection, signed Tailscale ingress, exact agent replies, ordered burst handling, and keep-open idle recovery are proven; full production qualification remains open.** The September 7 checkpoints below supersede the older login/credential gates and the intermediate unsolicited-recovery blocker.
## 2026-09-07 current live checkpoint
On `95cbbd08e`, the user-authorized PEM import connected **Paperclip Maya E2E
0906** (App ID `4853886`, installation `159668881`) to endpoint
`e516ceb3-397c-4a28-9640-1b2779515fb9`. The installation is restricted to two
private disposable repositories. The operator's `cryppadotta` identity is
linked to the local Board account through the private confirmation flow.
The App now sends signed webhooks through stable Tailscale Funnel origin
`https://dottas-macbook-pro.tail29c1aa.ts.net:10000`. Only provider webhook
ingress is public; the board remains local/private. The temporary Cloudflare
tunnel was stopped after a real signed issue comment reached Paperclip.
- [Issue 1](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/1)
created CHA-1 before identity linking and received the expected safe guest
refusal. That task retains its guest trust classification.
- [Issue 2](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2)
created CHA-2 after identity linking, with a receipt reaction. Its bundled
Codex ACP process incorrectly reported an unsupported-model provider error
as a completed assistant response, which was published to GitHub. This is a
release-blocking defect at that checkpoint, not a successful answer. Commit
`1325329e3` repairs the typed ACP terminal-error classification. The later
response-selection repair described below is separately required.
- [Disabled-repository issue 1](https://github.com/cryppadotta/paperclip-chat-e2e-disabled/issues/1#issuecomment-5571234021)
produced a GitHub webhook response **200 / ignored**, with no Paperclip
conversation or task. Provider installation access did not override the
Paperclip allowlist.
At `2026-09-07T13:45Z`, on `1325329e3` plus the final-response selection,
receipt, and scheduler working-tree changes, an unmentioned follow-up in
issue 2 requested exactly `GH-LIVE-0907-ROUNDTRIP-OK`. Run
`b7190e01-0176-4af7-a471-c1e013c2a015` succeeded and
[bot comment 5571558895](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5571558895)
contained exactly that response. The existing conversation and CHA-2 task
were retained. The setup UI subsequently completed and the endpoint is now
`active`.
The preceding live run had produced the correct model final but published an
earlier internal bookkeeping comment instead. External-chat runs now publish
only the runner-selected final; intermediate lifecycle comments remain
internal. A yielded or missing final cannot fall back to an internal note.
After the corrected reply, generic productive-task recovery incorrectly
started an unsolicited extra run. That separate queue defect is under repair;
the exact reply is not evidence that the entire interaction lifecycle passes.
The narrow recovery fix subsequently passed the full 133-case process-recovery
suite. A rapid three-message live test also retained all messages on CHA-2,
coalesced the last two into one deferred wake, and returned exactly
`DELTA EPSILON` without mixing Discord's distinct test words. Its two causal
runs took roughly 78 and 15 seconds. A keep-open task retest is still needed
to verify the recovery guard live, because this burst ended with the task done.
### Clean keep-open recovery qualification — 2026-09-07, 13:59 UTC
This checkpoint supersedes the pending keep-open retest above. On clean source
revision `5bd9c0d55`, an unmentioned follow-up on the existing CHA-2 issue left
the task deliberately `in_progress` and requested exactly
`GITHUB-IDLE-WAIT-OK`. Run `c3335bdf-6a2e-49a5-82eb-8d31df92e4d0` ran from
`13:59:33.398Z` through `13:59:39.464Z` and succeeded. GitHub
[bot comment 5571729974](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5571729974)
contained exactly that marker.
CHA-2 remained `in_progress` with its external conversation active for more
than eight minutes after the terminal reply. No additional run appeared. This
is live evidence that an idle, keep-open chat task is no longer mistaken for
stranded productive work, while explicit inbound and queued work remain
runnable. It supersedes the earlier checkpoint where generic recovery started
an unsolicited run after a successful reply.
### PR and review-comment boundary qualification — 2026-09-07, 14:29 UTC
A live pull-request boundary check used disposable private
[PR 3](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/pull/3),
branch `qa/chat-review-0907`, commit
`e5219350f17973895671f420c596de16852d1f10`, and the two-line file
`chat-review-0907.txt`. No repository operation was delegated to the agent.
The PR's main conversation received human comment `5572099126` and one
[bot reply `5572100025`](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/pull/3#issuecomment-5572100025)
containing exactly `GH-PR-LEVEL-0907-OK`. Paperclip bound provider thread
`github:cryppadotta/paperclip-chat-e2e-enabled:3` to conversation
`6f313c48-e684-421f-a730-dd68112c1e2c` and task
`5329b4bf-6b16-40d5-ad69-65bcbeac2ab3`. Run
`0d57af6e-2351-4bf2-8736-1d61cc877e67` ran from `14:29:10.041Z` through
`14:29:16.354Z`.
GitHub's current Files changed UI did not expose an actionable line-level
comment control during this walkthrough. The test therefore used **Comment on
this file** followed by **Add single comment**. Human review comment
`3950666444` received one
[bot reply `3950666803`](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/pull/3/changes#r3950666803)
containing exactly `GH-PR-REVIEW-0907-OK`. Paperclip bound the distinct provider
thread `github:cryppadotta/paperclip-chat-e2e-enabled:3:rc:3950666444` to
conversation `241f99a5-54ff-4bef-a0e7-313d69bf72b2` and task
`860c7878-f1a6-498d-995c-6feaa735eb27`. Run
`78deae9a-eb13-4374-a272-645ef1aec2d1` ran from `14:32:16.599Z` through
`14:33:29.285Z`. Its working publication at `14:32:17.750Z` and final
publication at `14:33:30.445Z` both settled through provider message
`3950666803` in one attempt, so progress-to-final used one edited comment rather
than producing duplicates.
This proves that a real PR main conversation and a real GitHub review-comment
thread on the same PR bind to different Paperclip conversations and tasks, and
that both can return an exact agent response. It does **not** qualify a
line-specific review comment: the exercised GitHub control was file-level. The
review reply also appeared only after a page reload. Its roughly 73-second
latency was dominated by a 72-second model turn (`ensure_session` was about
433 ms), not Paperclip queueing or provider transport; the result was correct,
but that wait remains a user-experience risk and prevents calling this path
fully production-ready.
### Image and file boundary — 2026-09-07
GitHub's native comment composer does not deliver uploaded bytes to the App.
It first hosts the upload and writes a reference into the comment body. In the
current GitHub UI, an image may appear as an HTML `<img src="https://github.com/user-attachments/assets/…">`
element rather than Markdown image syntax; a general file appears as a
Markdown link to `https://github.com/user-attachments/files/…`. Paperclip
retains a bounded set of safe HTTPS destinations in the normalized task text,
but deliberately does not fetch or store those provider-hosted bytes. GitHub's
[anonymized-URL rules](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls)
also mean the URL itself can be a capability, so it remains ordinary external
text rather than being republished as a Paperclip-owned attachment.
The inverse direction is also link-only. The GitHub App issue-comment and
pull-review-comment APIs accept a Markdown body, but expose no attachment-byte
upload field. Using GitHub CLI's `--attach` workaround would require repository
push access, which is intentionally outside this chat connection's Issues and
Pull requests permissions. Paperclip therefore must not claim that a checked
Board file was uploaded to GitHub. It now publishes an explicit limitation and,
only when the Board has a safe externally configured URL, an authenticated
Paperclip task link. A private/local Board produces a private-task notice with
no unusable localhost or webhook-ingress URL.
The task banner presents this provider-specific boundary before send: checked
files remain on the Paperclip task, while GitHub receives the authenticated
task link or the private-task notice. Focused adapter coverage exercises both
GitHub's native HTML image form and Markdown file-link form while asserting
that neither becomes a native attachment. Integration coverage asserts both
outbound fallback variants and that no provider file bytes or storage reads
occur. This is truthful link interoperability, not native GitHub file transfer.
A live issue-comment exercise then used GitHub's native upload UI with a known
image and a 128-byte text fixture. Human comment `5572301393` contained the
default HTML image reference plus the Markdown file link. Run
`0c252a02-51cc-4aeb-b829-73865415070e` ran from approximately `14:44:27Z`
through `14:46:37Z`. The
[bot reply `5572302077`](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5572302077)
did not fabricate either file's contents, which is the correct safety outcome,
but said that no authorized GitHub connection was available and suggested a
new connection request. That explanation is misleading: the GitHub **chat**
connection was active and transported the hosted links, but it intentionally
grants neither GitHub repository-tool authority nor credentials for fetching
provider-hosted attachment bytes. Chat-origin guidance must state that precise
boundary instead of implying the existing App is disconnected or requesting a
duplicate chat connection. Until that wording is corrected and the optional
separate-tool path is qualified, inbound GitHub media remains link-preservation
evidence, not readable-file qualification.
Focused adapter regression coverage now includes unmentioned follow-ups in PR
and review-comment threads plus the native review reply/edit HTTP boundary.
That file passed **3/3**, and the server typecheck passed. The broader
current-tree integration result remains pending after the latest causal issue
fence, so the earlier full-suite count is not advanced by this checkpoint.
The current split ingress topology keeps the board private. Public HTTPS
`:10000` remains available for the existing Slack and GitHub callback URLs;
public HTTPS `:8443` is the canonical webhook-only origin used for Telegram.
Both terminate at the narrow loopback proxy on port 3104. HTTPS `:443` remains
tailnet-only for the board, and the public webhook listeners do not forward
board health or company API routes.
After the latest setup-edge changes, the full chat integration suite passed
**258/258** and the combined process-recovery/status-payload suite passed
**135/135**, both with zero skips. The deterministic browser suite had passed
**5/5** on clean revision `5bd9c0d55`, but has not yet been rerun after the
latest setup-edge/UI changes; the current working tree is therefore not being
claimed browser-green here.
The [live addendum](2026-09-06-live-qualification-addendum.md) records exact
delivery and runtime evidence. Broader burst/fault coverage, recovery, reviews/PRs,
actions/files, and the rest of the release matrix remain open. All checkpoints
below are historical, not descriptions of the current login or credential state.
## Historical 2026-09-06 evidence checkpoint
The evidence boundary is unchanged but is now quantified more precisely:
- The archived endpoint `4e87c64e-7d0b-497d-85d2-6eb8820340fc` is genuine historical transport proof. One GitHub issue mapped to one Paperclip task; two inbound issue comments were recorded; an exact webhook redelivery folded into the existing delivery; and six outbound publications reached GitHub in one attempt each.
- The repository used for that historical proof was deleted during its authorized cleanup. Its former provider URL now returns HTTP 404, so it cannot be opened as current visual evidence and must not be cited as proof of the present source revision.
- Four agent runs in that historical task failed closed because the principal was unlinked and the instance had no low-trust isolation environment. That is a Paperclip governance boundary, not a GitHub transport failure, and it must not be presented as successful agent execution.
- The current draft endpoint whose id begins `a31` contains only a Paperclip-generated webhook secret. It has no verified GitHub App identity, private key, installation, repository, signed ping, conversation, or task.
- Current setup is stopped at GitHub's **Confirm access** MFA challenge. That is an external account gate, not an implementation defect. Current-source live qualification cannot resume until the account owner completes that challenge and creates/installs the disposable App.
### Release decision at this checkpoint
GitHub remains a release blocker for the five-provider claim. The current browser session is still stopped at the six-digit sudo-mode MFA prompt, before App creation, key generation, installation, signed ping, or any issue/PR/review webhook. Deterministic browser, integration, signature, lifecycle, concurrency, and permission tests establish implementation coverage only; they do not convert the historical deleted-repository run into current-source provider evidence. A temporary tunnel response would prove only that Paperclip's route is reachable, not that a durable production callback, GitHub App identity, or real event round trip is qualified.
## Historical setup-run evidence and blocker
- Last pre-merge setup-attempt source revision: `77ad5383e3a8badf7b1b0933a7e9c66469186d55`
- Latest implementation revision covered by focused checks: `83018c688`
- Signed setup-ping, one-time secret generation, App-identity, lifecycle, admission, and runtime hardening are committed in the current branch.
The current endpoint is back in the honest pre-connect state: `draft`, at the provider-setup step, with no App identity, App ID, private key, installation, resource, conversation, delivery, publication, or signed setup ping recorded. This is expected because the GitHub App has not been created yet.
### Pre-connect secret trap found and healed
The live setup attempt exposed a control-plane defect before GitHub credentials existed. Regenerating Paperclip's webhook secret was treated as rotation of a configured App, which moved the endpoint to `attention` and asked the operator to reconnect credentials that had never been supplied. That was a false degraded state, not a provider failure.
The committed fix distinguishes first-time setup from live credential rotation:
1. Paperclip generates a random 32-byte webhook secret server-side, vaults it through endpoint-owned secret references, returns the plaintext once from the board-authenticated setup-secret route, and marks the response `Cache-Control: no-store`.
2. Normal endpoint reads expose only `webhookSecretConfigured`; they never return the secret. The setup UI presents a read-only one-time copy value, then shows only configured state after refresh.
3. Generating or replacing a secret before any App identity/App credentials exist keeps—or heals—the endpoint to `draft` / provider setup with unchecked connection health. It clears any verification for the superseded secret but does not pretend a live App was degraded.
4. Rotating the secret after an App is configured remains fail-closed: it disables the runtime and requires the operator to update GitHub and reconnect.
5. Every generation is audited as `chat_endpoint.setup_secret_generated` with safe metadata indicating whether the operation was a live rotation; no plaintext secret enters the activity record.
6. The UI opens GitHub's new-App form for first setup, requires App ID and private key rather than pretending a secret-only endpoint is reusable, and explains the consequence before a real rotation.
The signed setup-ping path also accepts a correctly signed GitHub `ping` before App API credentials exist, records `chat_endpoint.webhook_verified` with only the safe provider delivery ID, and returns 401 for a missing or invalid signature. These were code and local-test results at the September 6 checkpoint; that App had not yet been created to send the ping.
## September 6 hardening checkpoint
The branch includes the following GitHub safety and concurrency behavior. These are code and local-test observations, not live GitHub qualification:
1. **Immutable App identity:** Paperclip binds the endpoint to the numeric App registration identity returned by GitHub, separately from the operator-entered App ID used to sign the App JWT. Reconnect and first-setup recovery from `attention` both revalidate an already claimed identity; credentials for a different App are rejected with `chat_bot_identity_changed`, including after a crash between identity claim and secret persistence.
2. **Signed setup-ping state and UI gating:** only a `ping` whose `X-Hub-Signature-256` validates against the current Paperclip-generated webhook secret sets `webhookVerifiedAt`. Missing or invalid signatures return HTTP 401. The setup UI polls this safe timestamp, displays waiting/verified state, and keeps **Connect and verify** disabled until the signed ping has arrived.
3. **Fail-closed secret rotation:** generating a replacement webhook secret clears the prior verification timestamp, removes the active runtime, degrades/disables the connection, and returns setup to the provider-update step. Reconnect remains blocked until GitHub sends a correctly signed ping using the new secret. Concurrent rotation/reconnect paths are serialized so stale credentials cannot overwrite the rotated secret.
4. **Atomic first-resource admission:** the first addressed setup repository is admitted inside the endpoint's serialized transaction. Concurrent root mentions from two initially disabled repositories can enable only one repository and create only its one conversation/task; the other repository remains disabled rather than racing through the first-resource exception.
5. **Runtime singleflight:** concurrent webhooks that arrive while a configured GitHub runtime is cold share one initialization promise. Paperclip installs one runtime and both requests proceed through it instead of racing duplicate adapter instances.
6. **Complete repository inventory:** GitHub installation-repository discovery follows successive 100-item pages, so an installation with more than 100 repositories is not silently truncated. Installation discovery likewise scans every page before enforcing the one-active-installation invariant.
7. **Retryable subscription without duplicate task state:** if the provider thread subscription fails after the task, external comment, wakeup request, and message link commit, the delivery remains retryable. A retry reuses those durable idempotent records, attempts the subscription again, and does not create another task, comment, or wakeup.
8. **Lifecycle revalidation:** installation creation or unsuspension re-authenticates the exact stored App identity and rechecks required permissions and events before recovery. App-ID, permission, or event drift fails closed: the endpoint moves to attention, the connection/runtime is disabled, resources and conversations remain unavailable, and the lifecycle delivery stays diagnosable/retryable rather than restoring access optimistically.
9. **Stable repository identity:** repository rename or transfer is reconciled through GitHub's immutable numeric repository ID. Paperclip preserves the resource, conversation, task, allowlist choice, and follow-up route while updating mutable owner/name coordinates and provider URLs; a conflicting dual-coordinate binding fails closed.
10. **Cold-start response budget:** the provider ingress deadline begins before runtime initialization. A signed webhook that cannot finish cold adapter startup inside the provider budget returns promptly and proceeds only through bounded durable retry instead of consuming GitHub's delivery timeout before Paperclip begins accounting for it.
11. **Provider-global App ownership:** the immutable numeric App registration
id has one live Paperclip endpoint even if GitHub transfers the App to a
different owner. Setup claims that id through a database uniqueness fence
before persisting App credentials; concurrent cross-company attempts leave
credentials only on the winner and do not reveal the owning company,
endpoint, or agent.
None of these local checks substitutes for exercising the same paths against GitHub's real App registration, installation, webhook redelivery, and suspension UI.
On merge revision `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`, the full chat-channel PostgreSQL integration suite passed 188/188 on fresh migrated database `chat_adapters_test_20260906_1140`; merge-conflict-focused server tests passed 355/355; and the deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts` passed 5/5 across Slack, GitHub, Teams, Discord, and Telegram. Implementation revision `83018c688` then passed the 42-test Discord adapter/runtime subset, the 34-test Discord/OpenAPI/UI contract subset, server/UI typechecks, token gates, a clean Discord patch application against the pristine package, and both working-tree checks. CI owns `pnpm-lock.yaml` and regenerates the PR lockfile artifact before its frozen install. Earlier provider-focused results remain valid regression evidence. These local results strengthen the setup path but do not change the live-provider blocker or qualification status.
The final combined working tree passed 193/193 chat-channel integration tests on fresh migrated database `chat_adapters_test_final_20260906_1257`, 111/111 focused runtime/error/privacy tests, all package typechecks, token gates, and the deterministic five-provider browser suite. This remains local evidence only for GitHub.
At the September 6 checkpoint, App registration and current-build provider delivery remained unexecuted because the signed-in session was stopped at GitHub's six-digit sudo-mode prompt. The later connected-App evidence above supersedes that setup gate without retroactively qualifying the unexecuted scenarios on the older revision.
## Historical-run scope
- Paperclip base used for the live run: `5da649986016e4010da8156f83f5bfc9c0128be4`
- Reconciled release base after the run: `342c01fee`
- Chat SDK / GitHub adapter: `4.39.0`
- Provider: GitHub.com, disposable personal-account App and private repository
- Paperclip endpoint: `4e87c64e-7d0b-497d-85d2-6eb8820340fc` (archived during cleanup)
- External conversation: `github:cryppadotta/paperclip-chat-e2e-enabled:issue:1`
- Paperclip task: `9ad34556-30b5-47a1-b207-ba666d8d897e`
No token, webhook secret, private key, cookie, password, or one-time identity-link URL is recorded here.
## Historical core-smoke result
The GitHub bring-your-own-App path passed the following core live round trip on `5da649986016e4010da8156f83f5bfc9c0128be4`:
1. Paperclip generated and stored the webhook secret without exposing it through normal endpoint reads.
2. A private GitHub App was created with Issues and Pull requests set to read/write and only the selectable `issue_comment` and `pull_request_review_comment` events requested. GitHub supplied installation lifecycle events automatically.
3. The App was installed on one selected private repository. Paperclip discovered that repository disabled by default.
4. A mention sent before Paperclip access was enabled was durably filtered with `Destination is not enabled in Paperclip`.
5. After enabling the repository, a root GitHub issue comment mentioning the immutable App bot created exactly one Paperclip conversation and one task.
6. A non-mention follow-up in the same GitHub issue remained in the subscribed conversation.
7. An explicit Paperclip board publication produced a GitHub bot reply and reached `published` state.
8. The setup test completed with endpoint status `active` and health message `Connected`.
GitHub accepted all qualified webhook deliveries with HTTP 200 once a public relay was available. The initial Tailscale hostname was tailnet-only, so the run used a temporary TLS relay and then shut it down.
## Deviation
The isolated test instance had no sandbox workspace provider. Its automatic low-trust agent heartbeat therefore failed closed with `low_trust_isolation_unavailable`. The transport round trip was completed using the audited, explicit **Send to channel** publication path. This confirmed inbound mapping, subscribed replies, outbound provider delivery, and setup activation without weakening the low-trust containment invariant.
## Cleanup
- Closed the disposable GitHub issue.
- Archived the Paperclip chat endpoint, which retired its endpoint-owned secrets but did not change any GitHub App registration, installation, repository grant, or webhook setting.
- Separately deleted all four disposable GitHub Apps in GitHub after qualifying the provider form and manifest paths.
- Deleted the explicitly disposable private repository `paperclip-chat-e2e-enabled`.
- Stopped the temporary registration server, public relay, and isolated Paperclip process.
## Historical local regression evidence
- Workspace build: passed.
- Shared, server, and UI typechecks: passed.
- Focused shared/UI/OpenAPI tests: 45/45 passed.
- Chat-channel PostgreSQL integration suite on fresh `chat_adapters_test_014`: 47/47 passed.
- Deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts`: 4/4 passed.
- Token gates and `git diff --check`: passed.
This evidence is useful for regression comparison, but it is incomplete release evidence. In particular, the full live runbook's issue/PR/inline-review boundary matrix, linked and unlinked identity authorization, reaction/edit lifecycle, text-only attachment fallback, burst/redelivery behavior, installation suspension/recovery, and all cleanup assertions were not all executed in this run. GitHub remains unqualified for stable release until the current source revision passes the complete live runbook.

View File

@ -0,0 +1,248 @@
# Slack live qualification result — 2026-09-05
For the later September 7 image/file handoff retake, see
[media qualification](2026-09-07-media-live-qualification.md).
For the September 89 native Luna media, queue and deployment evidence, use the
[current qualification ledger](2026-09-08-chat-queue-and-webhook-repair.md).
The checkpoint rows below retain their original tested revisions; they do not
qualify server 70 or the remaining live modal/Stop/governance permutations.
> **Status: broad current-branch live evidence plus historical core-smoke evidence, not full release qualification.** The current runs cover channel roots, DMs, FIFO follow-ups, reactions, edits, pause/resume, the registered Slack command, a command-created thread, native inbound and outbound files, disabled-resource enforcement and recovery, an interleaved command/status/final race, a complete native question-to-continuation round trip, and one revocation/relink sequence. Slack is still missing the rest of the governance, failure-injection, reinstall, and cleanup matrix.
## 2026-09-07 fresh-connection setup edge and recovery
The new `maya-e2e` app/endpoint `e3948092-3d92-46a5-9e19-525bd31a53eb`
reproduced the user's first-message failure on the isolated live instance. `CHA-5`
was admitted as an unlinked guest and failed closed with
`low_trust_isolation_unavailable`. The safety boundary was correct; the setup
experience was not: setup offered a test before explaining identity readiness,
then showed both a failed-run toast and an agent-wide error with a UUID.
The repair explains identity readiness in the test step, provides **Review identity
access** and a return **Continue setup** action, and never silently upgrades a
previously admitted guest task. Recognized pre-adapter low-trust admission failures
leave the healthy agent idle while preserving the failed run and safe external
refusal. The isolated-workspace case now receives one actionable warning using
the agent name. A failed refusal or a manually published Board comment cannot
qualify setup: completion requires the assigned agent's succeeded run and a
published final response.
Through the signed-in Slack and Paperclip interfaces, the observed `dotta` identity
was privately linked to Board. A **fresh root mention** created `CHA-6`
(`967ce77c-9b64-4f79-8e49-1bfc377ce908`). Run
`09d577dd-97e7-42d4-a2e4-9aeacac909d4` succeeded from
`14:06:01.690Z` to `14:06:08.599Z`, and Slack visibly showed exact
[`SLACK-LINKED-0907-OK`](https://papercliplabs.slack.com/archives/C0BUT55N9RV/p1788789962495849?thread_ts=1788789960.341109&cid=C0BUT55N9RV).
The task remained `in_progress`, waiting for external input without an unsolicited
recovery run through the `14:20Z` observation. `CHA-5` remains quarantined historical
failure evidence. The root receipt reaction was still visible; this retest does
not claim new Slack receipt-removal coverage.
A plain, unmentioned thread reply on the combined server then passed on the same
`CHA-6`: run `b3a2584b-ed42-4c3b-979b-427caf5b9267` succeeded from
`14:23:26.401Z` to `14:23:32.423Z`, and Slack showed exact
[`SLACK-THREAD-FOLLOWUP-0907-OK`](https://papercliplabs.slack.com/archives/C0BUT55N9RV/p1788791007411959?thread_ts=1788789960.341109&cid=C0BUT55N9RV).
The real **I've sent the test message** action then completed the wizard and
rendered **active** with no stale **Continue setup** action. The Settings page was
visually inspected after navigation. This verifies the original setup recovery
and the ordinary thread follow-up, not every notification animation or failure.
Source: `5bd9c0d55` plus the setup-edge repairs in this change. The live server was
restarted on the combined working tree at `14:20:00Z`. Fresh-database chat
integration passed **258/258**, recovery/status tests **135/135**, and focused UI
tests **47/47**. These are supporting regressions, not a replacement for the
remaining live runbook matrix or a comprehensive notification/transition audit.
The September 6 sections below remain historical evidence on their named builds.
## 2026-09-06 identity-revocation and callback-drift retest
The live endpoint's linked Slack identity was temporarily revoked before a uniquely marked direct message. Because **Allow unlinked people** was enabled, Paperclip admitted the message only as an external guest and created `CHA-88` under the low-trust quarantine profile. Execution failed closed when the instance could not provide isolated guest execution, and Slack received the safe link/isolation notice. The revoked principal did not retain the Paperclip user's membership or governance authority.
Restoring the identity link did not silently upgrade the already-created low-trust task. That generation remained quarantined, which is the safe boundary: trust is fixed at admission rather than changing underneath an existing task. After starting a fresh Slack generation, Paperclip admitted the now-linked principal normally; `CHA-89` reached `done` and Slack displayed exact `SLACK-LINK-RESTORED-0906`. This is live proof of immediate revocation plus safe fresh-generation recovery for one linked principal. It is not proof of every role-demotion, unlinked-disabled, governed-action, or concurrent revocation race in C3.
The fresh-generation attempt also exposed an operational callback-drift failure. Tunnel rotation had updated and verified the Events API and Interactivity URLs, but Slack's registered slash-command Request URL still pointed at the retired hostname. `/maya-fdhjew new` therefore returned Slack's visible `dispatch_unknown_error` until that third URL was updated. After the command callback was repaired, the same flow created `CHA-89` and passed as described above.
This was configuration drift across three independently stored Slack callback surfaces, not a message-queue or agent failure. It is nevertheless a release risk: an account-less Cloudflare quick tunnel is not production ingress, and an operator can otherwise have healthy events and buttons while commands are broken. Production deployment requires a durable HTTPS origin and a callback-health workflow that verifies Events API, Interactivity, and the registered command together after any origin change.
## 2026-09-06 merged-build ingress and FIFO retest
The live-tested merge commit is `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`. A first pair of direct messages sent against that build exposed the expected weakness of the temporary test ingress rather than an adapter failure: Cloudflare had retired the account-less quick-tunnel hostname, so Slack accepted the messages while Paperclip received no callbacks. After a new tunnel was created and Slack's Events API and Interactivity URLs were both re-verified, Slack's enabled delayed-event recovery delivered those two missed events. Paperclip processed each once and returned exact `SLACK-MERGED-A-0906` and `SLACK-MERGED-B-0906` responses in order. Later implementation revision `83018c688` changes only Discord log redaction plus documentation and setup copy relative to that tested Slack runtime.
A second pair sent 300 ms apart on the healthy ingress returned exact `SLACK-MERGED-C-0906` and `SLACK-MERGED-D-0906` responses in FIFO order. Because Slack DMs are a linear conversation, both messages intentionally used one active Paperclip task and serialized two agent turns. Each turn first published `Maya is working…` and then edited that same Slack message in place to the exact final. Across the four deliveries, the durable ledger contains four processed inbound rows and eight published rows (four working/final pairs), all with `attempts=1`, no error, and no pending, retry, failed, or ambiguous publication. This is positive recovery and queue evidence; the expired hostname confirms that a stable HTTPS origin is mandatory for production.
After the final combined hardening, all three Slack callback surfaces were rotated together to a fresh test origin and the current working tree was restarted on the migrated live database. `CHA-93` reached `done` and Slack displayed exact `SLACK-FINAL-SOURCE-2-0906`. Its inbound delivery processed once; working and final publications each completed with `attempts=1`, no error, and shared provider message id `1788717343.064939`. This is current-source smoke evidence for the Events API, slash-command reset, linked-principal execution, and in-place final publication. The account-less tunnel remains an explicitly non-production dependency.
## 2026-09-06 answer-handoff and channel-root retest
The later release-candidate working tree retained endpoint `2782e758-8e1e-47e3-a5aa-6a8359b1c23c` and repaired all three Slack callback surfaces after the development tunnel changed. Slack accepted the current Events API, Interactivity, and slash-command URLs, and delayed-event recovery remained enabled. This is useful current-provider evidence, but the temporary Cloudflare hostname is not a production ingress qualification; a stable deployment must keep a durable HTTPS origin across restarts.
The first DM answer retest (`CHA-82`) exposed a real handoff delay: an external answer stopped only native-mode source runs, so the legacy source continued until cancellation fallback and the continuation did not begin for about 23.5 seconds. The fix now cancels both native and legacy question-source runs and uses a compare-and-set terminal write so cancellation cannot overwrite a genuinely completed run.
The post-fix DM retest (`CHA-84`) showed the source run cancelled about 61 ms after the answer publication was created and the dedicated continuation queued about 93 ms after cancellation. Slack rendered exact `SLACK-HANDOFF8-Violet` about 20.8 seconds after the click; all five publications completed in one attempt, the task finished, and no generic or late duplicate followed. That elapsed time includes agent execution and publication, while the measured control-plane handoff itself remained sub-100 ms.
A separate enabled-channel root (`CHA-83`) produced one native Slack thread and one Paperclip task, then returned exact `SLACK-CHANNEL-FINAL-0906` in that thread with no cross-publication. The provider reply appeared about three seconds after the root. A live `+1` add and remove each produced one processed reaction delivery in roughly 34 ms of server handling, with `attempts=1` and no error.
## 2026-09-06 current-build interactive and reaction closure
An earlier pre-merge live retest ran the uncommitted release-candidate working tree based on `77ad5383e` after restarting the server with the same instance home and its then-current public webhook URL:
- Paperclip updated and Slack verified all three ingress surfaces: Events API, Interactivity, and `/maya-fdhjew`. A fresh direct message returned exact provider-visible response `SLACK-CURRENT-BUILD-0906`, and the registered `/maya-fdhjew status` command returned the current task without creating a task solely for the control.
- A live question whose optional `allowOther` field was omitted initially degraded to a link-only card. The shared schema defines that field as optional, so omission must mean a closed question unless it is explicitly `true`. After the fix, the same natural request rendered native **Red** and **Blue** controls. Selecting **Red** changed the card to **Answered: Red**, scheduled one continuation, and produced exact provider-visible `SLACK-RETEST-Red`; the generic completion did not race or follow it.
- A top-level DM reaction initially reached the Slack webhook but was discarded because the SDK supplied `slack:<DM>:<message-ts>` while Paperclip's linear DM binding is `slack:<DM>:`. The first fallback still chose the newest task generation and missed reactions on older linked messages. The final implementation resolves the exact owning generation through the durable message link, keeps the endpoint/reach/principal checks, and permits completed DM generations only for this audit-only event. A final live `+1` add and remove each produced one processed delivery; neither created a comment, task, run, wake, approval, or governed action.
- Heartbeat's resolved final-assistant presentation is now externalized only when the run has an exact causal chat binding. Ordinary internal runs keep `internal_agent_write`; chat-origin and native-interaction continuation runs receive the narrow `allow_chat_run_presentation` reason. This closes the earlier continuation gap without exposing reasoning, tool traces, or logs.
- When a chat-origin run creates a provider-visible native question or confirmation, that original prompt now consumes the run's external presentation slot. The run's meta-summary remains an internal Paperclip comment, the generic completion is suppressed, and only the distinct post-answer continuation may publish its final response. The rule keys on the exact source run and survives a fast-answer race.
## 2026-09-06 native file and action follow-up
Earlier provider checks on pre-merge revision `77ad5383e3a8badf7b1b0933a7e9c66469186d55` refined the evidence boundary:
- The disabled-resource negative and recovery path passed live. While the Slack resource was disabled in Paperclip, the provider message did not create a task or produce bot work. Restoring the permitted resource allowed a later request through without replacing the endpoint or losing its existing resource identity.
- The older `CHA-68` attempt is **not** outbound-file proof. Its explicit publication delivered text, but the separately created attachment was not bound to that comment's publication lineage, so Slack never received the intended native file. This exposed an implementation defect in the Paperclip comment/attachment handoff rather than a Slack transport rejection.
- After the explicit attachment binding fix, fresh task `CHA-71` passed the live outbound-file check. Paperclip bound the attachment to the explicit comment before publication; Slack then received the text followed by the native file, with each durable publication completing in one attempt.
- On `CHA-70`, selecting **Blue** on the native question card was accepted exactly once, the unselected sibling action expired, and Paperclip scheduled exactly one continuation. That older attempt exposed the missing continuation lineage. The current-build **Red** retest documented above supersedes it: the accepted-state update and exact continuation response both completed without a generic completion.
## 2026-09-06 final live extension
The active endpoint `2782e758-8e1e-47e3-a5aa-6a8359b1c23c` added the following current-provider evidence:
- Slack accepted the manifest with reaction and lifecycle subscriptions. At `08:36:54Z`, one-attempt `group_left` delivery for `C0BUT55N9RV` marked the resource unavailable. At `08:37:33Z`, one-attempt `member_joined_channel` restored it, hydrated the label to `#pc-chat-live-0905b`, and preserved the operator's enabled choice. The self-removal subscription and label-preservation defects found here were fixed; this was not an account or permission gate.
- Edits on `CHA-29` and `CHA-47` each produced one `message_updated` delivery and one internal system edit comment. Deleting the source message for `CHA-64` at `08:39:16Z` produced one one-attempt delivery and one internal deletion comment; deleted content was not republished.
- Repeated natural and slash-command DM generations worked. The latest natural DM, `CHA-67`, processed once at `08:42Z`, showed the receipt reaction, and returned exact final `slack-dm-live-final-0906` in about two seconds. An idle `status` returned `No task active` without creating a task.
- Inbound attachment proof includes the earlier 67-byte `text/plain` file on `CHA-52` and the current 41-byte file on `CHA-64`; both were persisted and read successfully, and the latter returned exact marker `paperclip-live-telegram-media-proof-0906`. The separate outbound proof remains `CHA-71`, where the explicitly bound Paperclip attachment reached Slack as a native file.
- `CHA-42` received two replies 144 ms apart. The second run began only after the first succeeded, and each run retained its own coalesced placeholder/final message. Four reaction-add and four reaction-remove callbacks also processed once each.
- Forty-five provider duplicate callbacks folded into 38 existing delivery rows without duplicate tasks or comments. All 97 earlier publications were `published`; all 44 runs after the isolation configuration succeeded. For the post-`05:00Z` sample, 24 processed inbound events averaged `0.702s` (`p50 0.781s`, `p95 1.146s`, maximum `1.646s`) and 25 publications averaged `0.488s` (`p50 0.315s`, `p95 1.103s`, maximum `1.127s`), all published.
The outbound-file and tested rich-interaction gaps are now closed. Broader modal/form behavior still needs live coverage. Earlier low-trust failures were governance isolation, and two old synthetic-command receipt warnings are preserved pre-fix evidence; neither is a current Slack account gate.
## September 56 source and evidence boundary
- Pre-merge source revision for the historical breadth checks below: `77ad5383e3a8badf7b1b0933a7e9c66469186d55`
- Most recently live-rerun Slack source revision at that checkpoint: `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`
- Later implementation revision (Discord log redaction and documentation/setup-copy follow-up only): `83018c688`
- The synthetic-command receipt, native thread binding, ordered task-control, coherent progress/status/final lane, explicit attachment binding, native-action lifecycle, final-presentation lineage, and top-level DM reaction-generation fixes are present in the final merge revision. The historical breadth checks exercised the pre-merge revision above; the merged-build section records the final live rerun.
- Live checkpoint: 2026-09-05 through 2026-09-06
- Live endpoint at that checkpoint: `2782e758-8e1e-47e3-a5aa-6a8359b1c23c`
- Paperclip issue: `d7f718da-a8da-468e-99a7-79dc337d5cbc`
No bot token, signing secret, webhook URL, cookie, password, or one-time identity-link URL is recorded here.
The sections below deliberately distinguish provider-visible proof from durable database evidence and local-only regression coverage. A successful local test is not reported as a live Slack result.
## Latest live breadth run
The latest live run added the following provider and durable-ledger evidence:
1. A channel root requesting the exact response `slack-prod-root-0906` produced one admitted mention delivery, one Paperclip task, and the exact provider-visible final response.
2. A normal DM requesting `slack-dm-prod-0906` produced one admitted direct-message delivery and one final response. Editing that source message produced one separate `message_updated` audit delivery and did not wake another agent run.
3. The registered immutable command was exercised in the real D-prefixed Slack DM. A `status` control and a following `new` control each produced one processed delivery with `attempts=1` and no error after the first receipt fix. The durable normalized record explicitly says those synthetic callbacks do not support a receipt reaction; no task was created merely to acknowledge the controls.
4. A command task requesting `slack-slash-task-a74` created exactly one `slash_task_start` action, one Slack starter message/thread, and one Paperclip task (`CHA-50`). Its working and final publications each completed in one attempt and shared the same provider message ID, so Slack showed one in-place final response rather than a progress/final duplicate.
5. A reply in that Slack thread requesting `slack-thread-followup-a74` produced one inbound delivery with `attempts=1` and no error. Because the preceding DM task was already terminal, Paperclip advanced the linear DM binding to its next session generation; that generation produced one working/final pair, again using one provider message ID, and the exact response was visible once.
6. A native file plus “Read the attached file and reply with exactly its Token value” produced one inbound delivery, one stored Paperclip issue attachment, and one final `chat-upload-a74` publication. The final publication completed in one attempt and replaced its working placeholder in place. This proves the tested Slack file-download and attachment-storage path for that file, not every Slack file type or size boundary.
7. On the final revision, `/maya-fdhjew Run sleep 12 then reply exactly slack-status-lane-6f13b` created native Slack thread `CHA-61`. While the run was active, `/maya-fdhjew status` replaced the working reply with the current `in_progress` state. The final then replaced that same reply with `slack-status-lane-6f13b`. Slack showed exactly one bot reply beneath `Starting a task…`, not stale working/status siblings. The working, status, and final publication rows all share provider message ID `1788679967.804189`; each is `published`, `attempts=1`, with no error.
### Synthetic-command receipt defects found during the run
The live command work found two related but separate bugs rather than treating the first patch as sufficient:
1. The first real DM `status` callback was represented internally by a deterministic hash because Slack slash callbacks have no native message to react to. The generic receipt path nevertheless sent that hash to Slack as a message timestamp. Slack returned `message_not_found`, leaving the processed delivery with a receipt-reaction error even though the status response itself continued. The fix persists `acknowledgement.receiptReactionSupported=false`, carries it through deferred reconstruction, and skips the reaction. A later live `status` and `new` both processed once with no error, which is live proof for this control-command branch.
2. The command-task branch had a second synthetic message after posting its real starter message. It still took the generic receipt path, so the otherwise successful `slack-slash-task-a74` delivery recorded the same `message_not_found` receipt error. The follow-up fix marks this branch unsupported too and adds regression coverage that Slack command callbacks never call `addReaction`, while Telegram commands retain their real provider message tuple and still do. The final live `CHA-61` command task and its interleaved status callback both processed in one attempt with no error and `receiptReactionSupported=false`, which is live proof of this second fix.
The earlier diagnostic rows remain preserved as bug evidence. The later clean rows, rather than rewriting history, provide the live regression proof.
### Post-run thread-binding and recovery audit
Reviewing the pinned Slack adapter after the live run exposed a third issue that the earlier fake runtime did not model: a slash command's `Channel` wrapper returns the channel wrapper id after a root post, while Slack's returned message timestamp is the actual native thread root. Treating the wrapper id as the task boundary can make later Paperclip publications appear as new top-level messages instead of replies under `Starting a task…`. The implementation now derives the canonical `slack:<channel>:<message timestamp>` thread id from the confirmed provider message and has a regression whose mock deliberately returns the non-thread channel wrapper id.
The same audit found that DM `status`, `new`, and `close` controls synthesized a base-DM thread id and therefore could not find a task created under the slash starter's native root. Those controls now resolve the most recently active task for that DM and route the synthetic control through its exact native thread binding.
Finally, an ambiguous starter post no longer remains an unactionable Activity row. Paperclip still never replays it automatically. Activity offers an audited **Retry anyway** only when the durable action contains complete reconstruction context, warns that both the starter and Paperclip task can duplicate, and offers **Cancel task start**. The retry revalidates the endpoint, destination, and original principal, serializes against endpoint mutation, and admits at most one concurrent retry. Older incomplete rows are cancel-only. Native thread binding and ordinary command creation were retested live; the deliberately ambiguous starter-recovery branch remains local-only because the provider failure was not injected live.
### Qualification-harness restart incident
The first final-revision command attempt returned Slack's “app did not respond” notice because the restarted local server was accidentally launched against the live database without its existing Paperclip instance home and encryption-key path. Secret resolution failed closed and no task was admitted. Restarting with the original instance home restored credential decryption, after which the same scenario passed. This is not a Slack adapter defect, but it is operational evidence that database restores and process restarts must preserve the Paperclip-generated master key; the database alone is intentionally insufficient.
## Earlier current-run evidence
A signed Slack root message reached the current public tunnel and completed the provider-visible lifecycle: the bot added its receipt reaction, showed a working response, and replaced or completed it with the successful final response in the originating thread.
Earlier unlinked-guest attempts reached Paperclip but failed closed at agent execution with `low_trust_isolation_unavailable`. After identity linking, the current root interaction completed successfully. The `037e57e0d` UX change now presents that containment failure as an actionable blocked-execution explanation instead of leaving the operator with a vague stopped-run state; this is a UX correction, not a relaxation of the low-trust isolation boundary.
The rapid two-message FIFO retest also passed:
1. Slack sent follow-up one and follow-up two in the same thread at `21:06:31.005` and `21:06:31.353` respectively.
2. Paperclip processed each delivery exactly once, with `attempts=1` and no error, at `21:06:32.054` and `21:06:32.286`.
3. All three runs succeeded with exit code 0 and were strictly non-overlapping. In UTC on 2026-09-06, the root ran from `02:05:50` to `02:06:04`, follow-up one from `02:06:32` to `02:06:44`, and follow-up two from `02:06:44.544` to `02:06:58`.
4. Slack displayed the two final replies in one-then-two order.
5. The root and two follow-ups produced six working/final publications total. Every publication completed with `attempts=1` and no error, and each working message was edited in place to its corresponding final response rather than producing an extra progress message.
This is direct evidence for single-thread FIFO serialization, exactly-once delivery processing in this burst, and working-to-final in-place edits. It does not replace the unexecuted Slack capability, governance, failure-injection, and recovery scenarios listed below.
The current public tunnel also passed a reaction round trip after the FIFO run. A user `+1` on follow-up two produced one `reaction_added` delivery for provider message `1788660391.353319`; removing it produced one `reaction_removed` delivery for the same message. Both were processed once with normalized `thumbs_up`/raw `+1` metadata and no redacted error. This proves the manifest's added reaction subscriptions are active on the current setup, not merely present in configuration.
### Latest false-duplicate and pause/resume retest
A subsequent pre-merge live retest produced the following evidence in UTC:
1. A root sent at `04:28:42` completed normally. Its durable delivery recorded one legitimate ignored duplicate caused by Slack exposing the same root through overlapping subscribed event shapes. This expected provider overlap remained deduplicated after removal of the separate false internal-drain duplicate counter.
2. Follow-up one and follow-up two were sent at `04:29:27.713` and `04:29:27.857`. Each processed exactly once with `attempts=1`, no error, and `duplicateCount=0`.
3. Their runs were FIFO and strictly non-overlapping: follow-up one ran from `04:29:28.507` to `04:29:50.777`, then follow-up two ran from `04:29:50.828` to `04:30:03.065`.
4. Each working/final publication pair completed with `attempts=1` and no error. The final publication reused the working publication's provider message ID, so each response was edited in place and no duplicate external reply appeared.
5. After the endpoint was paused at approximately `04:32`, `slack-paused-should-not-run` appeared in Slack but produced no bot reaction, no bot reply, and no Paperclip delivery. After resume, `slack-resume-ok` was accepted once and published one final response.
The later run also exposed a redundant automation follow-up wake inside Paperclip: the active run's own final comment carried `resume: true`, so it queued another wake even though the same run still owned the issue. The wake was deferred rather than run concurrently, and Slack received no duplicate external message, but the queue work was unnecessary. The fix now suppresses this narrow same-owning-run case while retaining explicit resume from a completed prior run.
A post-fix live retest at `04:58:32` sent `slack-no-empty-wake`. Paperclip admitted one message delivery, processed it once (`attempts=1`, no error), published working and final states once each by editing the same Slack provider message, and produced exactly one assignment wake for the incoming message. No automation follow-up wake was inserted by the agent's own final comment. Slack displayed one final `slack-no-empty-wake` reply.
## Pre-merge local regression evidence
- On that pre-merge working tree based on revision `77ad5383e`, the full chat-channel PostgreSQL integration suite passed 183/183 on fresh migrated database `chat_adapters_test_final_20260906_0833`.
- Focused shared tests passed 11/11, focused server tests passed 194/194, and focused UI tests passed 41/41.
- The deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts` passed 4/4, and shared, database, server, and UI typechecks all passed.
- These deterministic checks support the live continuation and reaction fixes but do not replace the remaining provider cases.
## Historical-run scope
- Paperclip release base: `342c01fee`
- Provider: Slack, disposable App and private channel in the Paperclip workspace
- Paperclip endpoint: `c3c20e8d-5dbd-49b7-9d7e-14068c9ded8b`
- External conversation: `slack:C0C0NFGUYKS`
- Paperclip task: `4a6dd0ca-d022-44c6-868d-7246169f3ef4`
No bot token, signing secret, webhook URL, cookie, password, or one-time identity-link URL is recorded here.
## Historical core-smoke result
The Slack bring-your-own-App path passed the following core live round trip on `342c01fee`:
1. The Paperclip manifest created a Slack App with the exact 16 required bot scopes, including reaction read/write support.
2. Slack accepted the Paperclip request URL for Events API delivery and interactivity.
3. Paperclip rejected neither the App identity nor its scopes and advanced the endpoint to live verification.
4. A root channel mention created one provider thread, one Paperclip conversation, and one Paperclip task assigned to the endpoint's immutable agent.
5. An unmentioned reply in the Slack thread stayed in the same Paperclip conversation and task.
6. Paperclip added the processing reaction, published lifecycle messages in the originating thread, and ignored Slack retry duplicates durably.
7. An explicit **Send to channel** board publication produced a Slack bot reply in the same thread and reached `published` state.
8. The setup test completed with endpoint status `active`, the discovered private channel enabled, and all Settings, Access, Conversations, and Activity views present.
The Activity view recorded both inbound deliveries as processed, showed provider retry duplicates ignored, and recorded all outbound messages as published.
## Deviation
The isolated test instance had no sandbox workspace provider. Its automatic low-trust agent heartbeat therefore failed closed with `low_trust_isolation_unavailable`. The transport round trip was completed using the audited, explicit **Send to channel** publication path. This confirmed inbound mapping, subscribed thread replies, outbound provider delivery, deduplication, and setup activation without weakening the low-trust containment invariant.
## Cleanup
- Archived the disposable private Slack channel.
- Removed the Paperclip Slack endpoint, retiring its endpoint-owned secrets. This did not uninstall the Slack app or remove it from channels.
- Separately deleted the disposable Slack App in Slack, revoking its bot token and signing secret.
- Stopped the temporary public relay and isolated Paperclip process.
## Historical local regression evidence
- Shared, server, and UI typechecks: passed.
- Focused UI/OpenAPI/server tests: passed.
- Chat-channel PostgreSQL integration suite on fresh `chat_adapters_test_016`: 47/47 passed.
- Deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts`: 4/4 passed.
- Token gates and `git diff --check`: passed.
This evidence is useful for regression comparison, but it is incomplete release evidence. Identity linking was sufficient for the exercised runs, but the full permission-revocation and unlinked-participant governance matrix was not executed. Current live evidence now covers disabled-resource enforcement/recovery, one native outbound-file fixture, and a complete native question continuation. Broader modal behavior, file type/size rejection, rate limiting and ambiguous-send recovery, full App uninstall/reinstall, reconnect, and final cleanup assertions remain incomplete. Slack remains unqualified for stable release until the complete release-candidate runbook passes.

View File

@ -0,0 +1,97 @@
# Microsoft Teams live qualification result — 2026-09-05
> **Status: blocked before provider setup; no live Teams scenario executed.** The branch contains substantial Teams hardening and local regression coverage, but none of it is real-provider evidence. This document is a blocker record, not a PASS.
## 2026-09-06 live-attempt checkpoint
Paperclip endpoint `00758007-1c59-45e9-bbef-3dc92c0fb20c` remains `draft` at `provider_setup`. Its connection has zero credential secret references, zero deliveries, zero conversations, and only the endpoint-creation audit row. At that historical checkpoint, the public messaging endpoint was reachable at:
`https://andy-constitutes-hockey-congressional.trycloudflare.com/api/chat-webhooks/2KMDqYFTcPXmEQVewVqmwMhBOnJyX7jJnzkjWOBNaqw/microsoft-teams`
An unauthenticated probe at that time returned the expected `409 chat_endpoint_runtime_unavailable` while the endpoint was draft. This proved public routing and fail-closed state handling for that temporary ingress, not Microsoft webhook authentication or a Teams round trip.
That hostname was an ephemeral development tunnel and is not a current callback or production ingress evidence. A later attempt must use the then-current callback and reconfigure Azure Bot if the tunnel has changed; stable release qualification requires a durable public origin.
The signed-in session is still the personal/free surface at `https://teams.live.com/v2/`. The exact external gates are:
- `https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade` — a Microsoft 365 work/school tenant identity allowed to register applications;
- `https://portal.azure.com/#create/Microsoft.AzureBot` — Azure subscription/resource-group permission to create the single-tenant Azure Bot used by the documented manual path; and
- `https://dev.teams.microsoft.com/apps` — custom-app upload permission, or tenant-admin publication/approval.
The live attempt did not progress far enough to observe a provider-side implementation defect. The blocker is the absence of a usable Microsoft 365 organization/tenant and its required provider permissions, not a Paperclip credential or webhook failure. Teams-focused local verification at this checkpoint passed 28 focused server tests, 11 shared credential-validation tests, and 12 fresh-database integration tests; those results remain local evidence only.
### Release decision at this checkpoint
Teams remains a release blocker for the five-provider claim. The signed-in account reaches Teams personal/free, but that identity cannot complete the organization-backed Entra application, Azure Bot, Teams Developer Portal, and custom-app installation path. No Teams credential, authenticated Bot Framework activity, conversation, task, publication, card action, or reconnect has been observed live. Deterministic and fresh-database coverage is valuable implementation evidence but cannot substitute for a Microsoft 365 work/school tenant and, where required, tenant-administrator approval. The earlier quick-tunnel route probe is not production ingress qualification.
## Production-readiness audit — 2026-09-06
The Teams connector is **not yet live-qualified or production-ready**. A code-level stress audit found and fixed additional defects:
- When direct-message reach was disabled, Paperclip filtered the turn before creating a task but had already persisted the full message and external principal. Admission now reads the current DM switch under the endpoint row lock and stores only a payload-free delivery envelope.
- Outbound Teams files were passed to the pinned adapter in personal chats as though this were a native upload. The adapter only created a base64 data-URI activity attachment; it did not implement Microsoft's required file-consent card, accept invoke, provider-issued upload URL, upload, and file-information card sequence. Paperclip now publishes a safe task link for Teams attachments in every conversation surface instead of making that unsupported provider call.
- Reach authorization was checked before the later issue/comment mutation, leaving a stale-admission window. The final task mutation now locks and revalidates the endpoint plus destination in one transaction for every provider. If DM, group, channel, repository, or chat reach was revoked first, Paperclip atomically stores only a payload-free filtered delivery, removes the event's otherwise-orphaned external principal, and creates no task or comment. Deterministic fresh-database races cover all three Teams reach controls and a Slack DM.
- The pinned Teams adapter did not dispatch `messageUpdate` activities even though its public parser and the SDK expose the contract. Paperclip now supplements verified `editMessage` activities through that parser, deduplicates exact redelivery while preserving same-timestamp/different-body edits, persists the actor, and revalidates current principal authorization under lock before adding the lifecycle comment.
- Opening a Slack or Teams form previously had a final authorization race after its first link check. Endpoint, destination, principal, and interaction authorization are now revalidated under the mutation lock immediately across provider modal opening, so revocation or demotion cannot race a stale modal into existence.
- Public chat webhooks now use a dedicated 1 MiB raw-byte parser before the generic application parser. Declared oversize bodies fail before materialization, chunked bodies are stream-capped, content encoding fails closed, and exact raw bytes remain available for signature verification.
- The pinned Teams adapter exposes its public `parseMessage` contract but does not dispatch Bot Framework `messageUpdate` activities. Paperclip now supplements the authenticated webhook path for the documented `messageUpdate` plus `channelData.eventType=editMessage` envelope, preserving the adapter's canonical thread and principal mapping. Concurrent duplicate callbacks collapse to one lifecycle row, while distinct edits with the same provider timestamp remain distinct through a content-bound revision key.
- Opening a Teams question modal previously rechecked authority before resolving the form but not at the final provider-effect boundary. Paperclip now locks and revalidates the endpoint, destination, principal link, and Paperclip membership before opening the modal. A deterministic race proves that demotion from operator to viewer during the callback prevents the modal from opening.
- Telegram and Teams edit lifecycle rows now retain the normalized external actor and perform the same locked principal authorization check before creating a Paperclip system comment. If an identity link or membership is revoked after webhook receipt, the late edit is filtered and its text is redacted from the durable delivery row.
- Authenticated Teams `messageDelete`/`softDeleteMessage` and
`messageUpdate`/`undeleteMessage` activities are now supplemented alongside
edits. Exact callback duplicates collapse durably, edits cannot resurrect a
deleted source message, and a later provider restoration reopens the
lifecycle before subsequent edits. The advertised delete capability now
matches this implementation.
- GitHub App ids and Microsoft Bot application ids now have provider-global
live ownership constraints independent of mutable owner or tenant metadata.
Setup claims the identity before persisting newly supplied credentials, so
concurrent cross-company setup has one winner and leaves no credentials on
the loser without revealing the other company's endpoint or agent. If setup
crashes after that claim, a later `configure` recovery must still match the
claimed Bot application id; it cannot use the attention state to replace the
immutable bot identity.
The final combined working tree passed 193/193 chat-channel integration tests on fresh migrated database `chat_adapters_test_final_20260906_1257`, 111/111 focused runtime/error/privacy tests, all package typechecks, token gates, and the deterministic five-provider browser suite. This remains local evidence only for Teams.
The setup wizard also now provides an exact Entra, Azure Bot, Teams Developer Portal, and Teams custom-upload field map plus a copyable Paperclip-specific manifest block. It explicitly distinguishes that block from a complete app package, so operators are not left to infer where each value belongs. At the time of this qualification record, the block omitted `webApplicationInfo` because Paperclip does not use Teams single sign-on. **September 7, 2026 correction:** Teams requires `webApplicationInfo` to bind the declared RSC permissions to the Entra app even without SSO. The current wizard includes that binding with a nonempty RSC-only resource; Paperclip still does not require registering an Entra Application ID URI or adding delegated Microsoft Graph permissions.
One remaining risk requires real-provider evidence before a production claim: private denial notices use Teams targeted messages. Microsoft moved this feature to general availability on July 30, 2026, although the pinned adapter README still calls it public preview. The local suite proves the adapter call contract, but the denial, removal-from-roster, and bounded-fallback paths still need real-provider validation.
The remaining live matrix is unchanged: installation, real webhook authentication, channel/root/reply ordering, DMs and group chats, reactions, Adaptive Card actions, identity linking, provider revocation, file receipt and publication, reconnect, retry, and cleanup have not run against Microsoft Teams. Paperclip endpoint removal archives the connection and retires its saved client secret; it does not delete the Entra registration or Azure Bot, remove the custom app package, or uninstall that app from teams and chats.
## Attempted environment
- Last pre-merge live-attempt source revision: `77ad5383e3a8badf7b1b0933a7e9c66469186d55`
- Latest implementation revision covered by focused checks: `83018c688`
- Teams FIFO, endpoint-generation fencing, per-thread and per-user service-URL egress, adapter compatibility, reach defaults, and pre-transport safety fixes are committed in the branch.
- Provider session: Microsoft Teams personal/free at `teams.live.com`
No Microsoft client secret, access token, cookie, password, or one-time identity-link URL is recorded here.
## Blocker
The signed-in personal/free Teams account cannot access the organization-backed Teams Developer Portal, Entra registration, Azure Bot, and custom-app installation path required for the customer-owned bot. Navigating into that path reaches Microsoft's work-or-school organization gate. Live setup requires a Microsoft 365 work or school tenant with permission to create a single-tenant Entra application and Azure Bot, configure a custom Teams app, and install it or obtain tenant-administrator approval.
The run stopped before credential entry and before any provider webhook activity. A Microsoft 365 tenant login, and possibly tenant administrator approval, is required before live qualification can begin.
## Code qualification progress
The committed code serializes Teams turns in FIFO order and stores the latest Bot Framework `serviceUrl` as mutable route state per external conversation (plus per user for direct-message creation), outside the durable thread identity. Existing route-bearing thread IDs remain readable, while new IDs are canonical and route-free; a signed activity that arrives through a new regional route therefore continues the same Paperclip task. Every outbound operation uses an asynchronous context-local API client and the latest admitted route, so simultaneous conversations in different Microsoft regions cannot overwrite one another's route. The shipped setup is qualified only for Microsoft 365 commercial cloud tenants. Its defensive trust boundary accepts Microsoft-owned Connector host families or an exact explicitly configured API URL because signed activity carries the reply route; accepting a host is not sovereign-cloud qualification. Loopback, attacker-suffix, nonstandard-port, and wrong-path destinations fail before transport, and those local rejections are classified as definite failures rather than ambiguous `delivery_unknown` sends.
Additional hardening from this cycle is also local-only:
- Teams group chat reach now defaults to off. The change is delivered through forward-only migration `0244_tan_chat.sql`, so existing databases upgrade without rewriting migration history; group discovery no longer silently enables group reach.
- A reply that arrives before its root remains retryable and ordered instead of creating a detached task or being acknowledged as complete too early.
- Native file ingestion is limited to personal chats, where the bot file contract applies. Team channels and group chats retain bounded attachment metadata and provider links without invoking unsupported credentialed downloads.
- Runtime callbacks are fenced to the endpoint credential generation, and pause, reconnect, rotation, and removal share a mutation lease so an old callback cannot mutate task state after endpoint state changes.
- Stale or unauthorized Teams actions fail safely with a targeted payload-free notice; provider-retry duplicates cannot execute the action repeatedly.
- The UI does not present misleading DM/group rows as independently discovered destinations when Teams reach is controlled by the connection-level access switches.
The pinned adapter contract now fails initialization if the internal API client or any wrapped outbound method drifts. Published-adapter tests directly exercise post, edit, reaction add/remove, delete, and concurrent cross-region `openDM`; a fresh-database integration test covers the terminal pre-transport failure and the Teams delivery reorder window. Focused runtime/classifier tests passed 54/54, the published-adapter/classifier subset passed 27/27, the fresh PostgreSQL Teams subset passed 9/9, and server typecheck passed. These results address ordering, regional isolation, SSRF exposure, and error classification in code, but remain local evidence until exercised through a real Microsoft 365 tenant.
On merge revision `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`, the full chat-channel PostgreSQL integration suite passed 188/188 on fresh migrated database `chat_adapters_test_20260906_1140`; merge-conflict-focused server tests passed 355/355; and the deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts` passed 5/5 across Slack, GitHub, Teams, Discord, and Telegram. Implementation revision `83018c688` then passed the 42-test Discord adapter/runtime subset, the 34-test Discord/OpenAPI/UI contract subset, server/UI typechecks, token gates, a clean Discord patch application against the pristine package, and both working-tree checks. CI owns `pnpm-lock.yaml` and regenerates the PR lockfile artifact before its frozen install. This does not change the Microsoft 365 organization/tenant blocker or provide live Teams evidence.
## Qualification gap
All Teams live scenarios remain unexecuted: credential/setup verification, custom-app installation, team/channel discovery and enablement, channel root/reply boundaries, direct and group chats, identity linking and governance, Adaptive Cards/actions, native DM file handling and non-DM file fallback, post/edit publication behavior, edit/delete/restore receipt, duplicate delivery, permission or app revocation, reconnect/recovery, and cleanup. Deterministic local tests do not replace this missing real-provider evidence.

View File

@ -0,0 +1,209 @@
# Telegram live qualification result — 2026-09-05
For the later September 7 image/file handoff and real download checks, see
[media qualification](2026-09-07-media-live-qualification.md).
> **Status: broad private-chat and group/topic live evidence, not full release qualification.** The latest live runs cover task controls, FIFO and burst handling, reactions, edits, native documents, task-generation races, the repaired interleaved status/final lane, group/topic isolation, removal/rejoin, the silent-publication boundary, and a complete native confirmation-to-continuation round trip. Broader media boundaries, global token revocation, and other runbook cases remain open.
## 2026-09-07 fresh-bot webhook-port and setup recovery
The user's new bot connection exposed a deployment/setup defect: the public
Tailscale origin used port `10000`, which Telegram rejects. The connector now
validates HTTPS and Telegram's allowed webhook ports **443, 80, 88, 8443** before
provider access, credential writes, or reconnect lifecycle changes. Unsupported
configuration returns the actionable `chat_telegram_webhook_url_unsupported`
error without including the supplied URL or token. Regression coverage verifies
both first setup and preservation of existing durable credentials on reconnect.
The canonical Telegram origin now uses public Tailscale Funnel **8443** forwarding
only to the webhook-only proxy on `3104`. Public `10000` remains a compatibility
route for already-configured Slack/GitHub callbacks; private tailnet `443` still
serves the Board. Public Board/health/API requests return `404`. This supersedes
the older temporary-tunnel topology below and the earlier private-8443 checkpoint.
The real **Reconnect bot** action reused the already-vaulted token successfully;
the user did not need to create another bot or re-enter a secret. The verified
bot is [MayaPaperclipQA1234bot](https://t.me/MayaPaperclipQA1234bot), endpoint
`5b18b946-2b24-45b6-957f-783a0a735d8a`. Tapping **Start** discovered the account and
displayed the native welcome without starting a failing agent run. The observed
account was privately linked through Access, and **Continue setup** returned to
the test step. Live inspection also found that **Open Telegram** incorrectly
pointed back to BotFather; setup now projects the verified bot's own URL.
The linked private-chat request created `CHA-8`
(`8feb3fca-5fdc-4659-bd99-e11c3f64d032`), conversation
`0e63c3fb-026f-49c1-af40-383d42cb5cfd`. Run
`e1e53d28-8d96-44ec-bf16-9ec5660ccca4` succeeded from
`14:15:31.904Z` to `14:15:39.979Z`. Telegram visibly showed exact
`TELEGRAM-LINKED-0907-OK`; the final published at `14:15:40.994Z`. Working and final
each published in one attempt and share provider message `417200359:4`, proving
an in-place update rather than two bot messages. The task remained `in_progress`
and its conversation active; the company had no pending, retry, streaming, or
ambiguous publication at the `14:20Z` audit.
The real **I've sent the test message** action completed setup on the restarted
server and rendered **active**, with no stale **Continue setup** action. The
Settings page was visually inspected. **Open Telegram** is now a native link
with the verified bot URL rather than BotFather. Its href was verified in the
live UI, but clicks did not create a tracked in-app-browser popup even after the
native-link change; no visible blocker appeared. That host/external-link behavior
remains unverified, and the working bot conversation is separate live evidence,
not a claim that this popup opened successfully.
Source: `5bd9c0d55` plus the setup-edge repairs in this change, with the combined
server restarted at `14:20:00Z`. Fresh-database chat integration passed **258/258**,
recovery/status tests **135/135**, and focused UI tests **47/47**. This fresh-bot
core journey does not rerun or upgrade every historical group/media/governance
case below to the current source.
## 2026-09-06 merged-build tunnel-rotation retest
The live-tested merge commit is `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`. After the account-less Cloudflare test tunnel expired, the bot webhook was rotated to the new verified URL with the already-vaulted token and webhook secret; neither credential was exposed. Telegram reported zero queued updates and no provider-side webhook error. A fresh `new` followed by a `task` command created one task and produced exact `TELEGRAM-MERGED-A-0906`. Its working placeholder and final share provider message ID `-1004415501660:69`, proving that the final edited the status in place. Both inbound command deliveries processed once and both publications completed with `attempts=1`, no error, and no pending, retry, failed, or ambiguous row. A plain unaddressed group follow-up was intentionally not delivered to the bot under Telegram privacy mode. Later implementation revision `83018c688` changes only Discord log redaction plus documentation and setup copy relative to that tested Telegram runtime.
A later group/topic iteration repeated the privacy and exact-publication path. Telegram delivered `/new` once, intentionally withheld the following plain unaddressed text under privacy mode, and then delivered the explicit `/task` command. Paperclip created only `CHA-90`, completed it successfully, and published working plus exact final `TELEGRAM-ITERATION-0906` with `attempts=1`, no errors, and the same provider message id `-1004415501660:74`. This is additional live evidence for command admission, privacy enforcement, and working-to-final in-place editing; it does not exercise the reconnect backlog repair below.
## 2026-09-06 reconnect backlog-preservation audit
The successful tunnel-rotation retest above had zero queued provider updates, so it did not exercise recovery of a backlog. A later code audit found that the reconnect path asked Telegram for `drop_pending_updates=true` whenever the public webhook URL changed. During a real ingress outage or domain migration, that option could silently discard messages Telegram had queued while Paperclip was unreachable. The clean exact response above remains valid positive transport evidence, but it cannot be cited as proof that queued updates survived a reconnect.
The working-tree repair now distinguishes first setup from recovery:
1. Initial bot setup may drop updates that predate the Paperclip connection.
2. Every reconnect preserves pending updates, including a reconnect that changes the public webhook URL.
3. Endpoint removal continues to delete the webhook without requesting a pending-update drop.
The shipped removal boundary also deletes Paperclip's registered command menu through the same durable maintenance outbox, then retires the saved token. It does not delete the BotFather bot or remove that bot from chats; those remain explicit provider-side cleanup steps.
The focused fresh-database regression passed 1/1, the adjacent reconnect subset passed 5/5, server typecheck passed, and formatting/diff checks passed.
The final working-tree retest then exercised the provider failure mode directly. Telegram updates `75` (`/new`) and `76` (`/task`) were sent while the prior quick-tunnel hostname was dead and therefore remained queued at Telegram. Paperclip restarted on the migrated current source, reconnected the bot to a fresh public origin, and preserved both pending updates. They arrived in provider sequence, processed once each with `attempts=1`, and created only `CHA-91`. The task reached `done`; its working state and exact final `TELEGRAM-FINAL-SOURCE-0906` each published once with no error and shared provider message id `-1004415501660:78`. Telegram Web visibly showed the exact final. This upgrades this specific backlog-preservation path from deterministic-only evidence to one live outage/rotation/replay pass; provider flood control, token revocation, and the rest of the failure matrix remain open.
As with Slack, the account-less Cloudflare quick tunnel was useful for finding and live-verifying the defect but is not production ingress. Stable qualification still requires a durable HTTPS origin and the remaining TG recovery cases on the final release-candidate source.
## 2026-09-06 final answer/recovery audit
The final transcript and durable-ledger review for `CHA-81` found one provider-visible exact final publication, `TELEGRAM-LATENCY7-Cobalt`, with `attempts=1`; Telegram also showed the native question card settled to **Answered: Cobalt**. No late duplicate or internal run summary reached the provider.
A third recovery run did execute after the answer continuation. Its comment stayed internal and the task then reached `done`, so the externally visible safety boundary held. The extra recovery incurred about $0.21 of model cost and is retained as efficiency evidence: it is the intentional productive-terminal fallback that prevents an `in_progress` task from being stranded, not a second answer publication. This observation does not upgrade Telegram to a complete runbook pass, and future tuning should preserve that liveness guarantee while avoiding unnecessary work when the continuation has already terminalized the task.
## 2026-09-06 current-build continuation closure
After the public test tunnel changed, Paperclip rotated the bot webhook to the current verified URL using the already-vaulted credential; no token was exposed. The first current-build request then exposed a real shared presentation defect: the exact final comment existed in Paperclip, but Telegram received only `Maya completed this turn.` because heartbeat materialized the final response as an internal comment.
The repaired path now authorizes only the selected final-assistant presentation of an exactly chat-bound run. A fresh request produced exact provider-visible `TG-CURRENT-BUILD-0906-C` instead of a generic completion. A fresh ordinary confirmation then rendered native **Approve** and **Reject** controls; selecting **Approve** edited the card to **Accepted**, scheduled one continuation, and produced exact provider-visible `TG-CONFIRM-CONTINUED-0906`. The final response appeared once, and no generic completion followed it. Raw reasoning, tool events, and internal logs remain in Paperclip.
Transcript review then found that the originating run's own meta-summary still appeared beside the native control and exposed internal interaction terminology. The final implementation keeps that source-run summary internal whenever its exact provider-visible interaction prompt exists, including when the user answers before presentation resolves. The native prompt and the later continuation remain external.
## 2026-09-06 native confirmation follow-up
Earlier provider checks on pre-merge revision `77ad5383e3a8badf7b1b0933a7e9c66469186d55` distinguished the native control from its downstream continuation:
- The older confirmation attempt exposed a link-only fallback gap and is not evidence for native Telegram actions.
- A fresh confirmation on provider message `521…` displayed native **Yes** and **No** controls in Telegram. Selecting **Yes** was accepted exactly once, the sibling choice expired, and the same provider message was edited to **Accepted** with no buttons left active. Paperclip scheduled exactly one continuation.
- The continuation run's final comment remained internal because its run lineage was not recognized as originating from the bound external turn. That older attempt exposed the defect. The current-build **Approve** retest documented above supersedes it and completed the native question-to-continuation round trip with exact final output.
## 2026-09-06 group and boundary extension
The live bot was installed in group `pc-e2e-telegram-0906`; the endpoint remained live through the following cases:
- **Captioned media defect and fix:** a 41-byte `text/plain` document initially normalized with zero attachments because the slash-command callback did not invoke the pinned Telegram adapter's `parseMessage`. The implementation now uses that parser for Telegram command captions. The live retry stored the durable attachment, the agent fetched it with HTTP 200, and Telegram received exact response `paperclip-live-telegram-media-proof-0906`.
- **Topic isolation:** custom topic id `2` mapped to task `CHA-65` and native thread `telegram:-1004415501660:2`; General mapped separately to `CHA-66` and `telegram:-1004415501660`. No cross-topic task reuse was observed.
- **Queue ordering:** A and B were sent six seconds apart. B was admitted only after A succeeded, the placeholder/final lane coalesced, and the exact final marker `tg-queue-A-then-B-0906` was visible. This is live FIFO evidence for one group conversation, not a universal throughput benchmark.
- **Removal, rejoin, and migration:** `my_chat_member` plus the basic-group-to-supergroup migration marked the old resource unavailable and the new resource available once, while restoring the human group label. One stale legacy basic-group inventory artifact created before the fix remains in this disposable database; future migration and membership events use the corrected behavior. The artifact is historical local state, not a current provider failure.
- **Silent publication boundary:** after the `03:44` restart, a prompt explicitly forbidding a public comment produced only generic provider text `Maya completed this turn.` Internal presentation comments are no longer auto-published. Explicit `allow_*` and runner-authored comments remain eligible. The unwanted auto-publication was an implementation defect and the live rerun verifies the fix.
## Scope
- Pre-merge source revision for the historical breadth checks below: `77ad5383e3a8badf7b1b0933a7e9c66469186d55`
- Most recently live-rerun Telegram source revision: `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`
- Later implementation revision (Discord log redaction and documentation/setup-copy follow-up only): `83018c688`
- Telegram provider-ordering, slash-command receipt, false internal-drain duplicate, stale-action denial, endpoint-generation fencing, command admission, provider-failure classification, coherent progress/status/final lane, native-confirmation lifecycle, and exact final-presentation lineage fixes are present in the final merge revision. The historical breadth checks exercised the pre-merge revision above; the merged-build section records the final live rerun.
- Provider: Telegram, dedicated test bot in a private chat
- Live checkpoint: 2026-09-05 through 2026-09-06
No bot token, webhook secret, cookie, password, or one-time identity-link URL is recorded here.
## Latest live breadth run
### Commands and linear task generations
The live private chat exercised `new`, `status`, and `close` as real Telegram commands. The recorded command deliveries used provider-native `chat_id:message_id` identities, processed with `attempts=1`, and had no redacted error. The task request after `new` returned the exact `telegram-command-prod-a74` response once. A later `status` reported the active task, and `close` closed the linear binding before the next generation.
Telegram commands continue to receive the normal provider receipt reaction because, unlike Slack slash callbacks, Telegram supplies a real message ID. Local regression coverage now asserts that this capability difference survives deferred delivery reconstruction.
### FIFO, bursts, reactions, and edits
The live ledger and provider UI showed:
1. The exact requests `tg-prod-fifo-one` and `tg-prod-fifo-two` were admitted once each and returned their matching final publications in provider order. Each final publication completed in one attempt with no error.
2. A tighter same-second burst, `tg-prod-rapid-three` followed by `tg-prod-rapid-four`, produced two processed inbound deliveries and two one-attempt final publications in three-then-four order. The two wakes were allowed to coalesce operationally without merging, dropping, or reversing the externally visible results.
3. Removing and then adding a reaction on provider message `417200359:143` produced one `reaction_removed` and one `reaction_added` delivery. Both processed once with no error.
4. Editing a source message produced separately auditable `message_updated` deliveries for the provider message, without treating the edit as a duplicate of the original inbound event or starting an unintended replacement task.
### Native file proof
A Telegram document plus “Read the attached file and reply with exactly its Token value” produced one processed direct-message delivery, one stored Paperclip issue attachment, and the exact `chat-upload-a74` final response. Its working and final publications each completed in one attempt, and the final edited the working provider message in place. This proves the tested document path only; photos, audio, video, oversize files, malformed files, and download-failure recovery remain separate cases.
### Queued `new` generation race
The run deliberately put a slow task in one Telegram DM generation, sent another `new`, and then started a new task before the older task finished. The durable state shows distinct consecutive bindings (`CHA-54` and `CHA-55`) on the same Telegram chat. Both inbound requests processed once and both final publications succeeded once. The newer generation returned `telegram-new-generation-a74` before the older generation later returned `telegram-old-generation-a74`; neither final overwrote or attached to the other generation.
This is useful proof of generation isolation, not strict global FIFO across generations. Paperclip intentionally gives each task generation its own provider publication lane, so an older still-running task may finish after a newer one. The current run did not test cancellation of the old run, because `new` defines a new active binding rather than cancellation semantics.
### Delayed-status chronology defect found live
The sequence `new`, a delayed task request, then `status` exposed a provider-visible chronology problem. Status was sampled as `in_progress` and posted after the working placeholder, but the older final response later edited that earlier placeholder in place. Telegram therefore rendered the final answer above a now-stale-looking status message. Every transport operation succeeded, but the resulting conversation was not production-quality.
The final fix makes a task-bound status a durable `task_control` publication in the same conversation FIFO, re-samples authoritative task state at the outbox head, and treats the active run's provider message as one coherent lane. Status edits the open run's queued/working message; the final edits that same provider message again. Once terminal output exists, a later status has no open placeholder and posts separately instead of erasing the final.
The live final-revision rerun used `new`, then `Run sleep 12 then reply exactly tg-status-lane-6f13`, then `status` while `CHA-62` was active. Telegram showed the current `in_progress` state while the run was active and later showed only the final `tg-status-lane-6f13` in that bot-message position. There was no stale `Maya is working…` or `in_progress` sibling. The working, status, and final publication rows all share provider message ID `417200359:199`; each is `published`, `attempts=1`, with no error.
## Latest false-duplicate regression retest
On 2026-09-06 UTC, Telegram update `128` (`/new`) arrived at `04:26:26` and update `130` (the root request) arrived at `04:26:32`. Both deliveries processed with `attempts=1`, null errors, and no `duplicateCount` field, which represents zero duplicates. The exact final response `tg-no-false-duplicate` appeared promptly in Telegram.
Earlier delivery rows intentionally retain the false duplicate telemetry produced before the fix. They are preserved as bug evidence rather than rewritten to resemble the clean retest.
## Core-smoke result
The following private-chat behavior was observed on the recorded working tree:
1. Telegram delivered sequence `118` (`/new`) and sequence `119` (the next request) with the same second-resolution `sentAt` value.
2. The corrected ordering uses Telegram's raw provider date together with monotonically increasing `message_id`, so Paperclip processed `/new` before the request even when their normalized timestamps tied.
3. The corrected slash-command normalization preserved provider message ID `417200359:118`; the provider receipt reaction succeeded and the durable delivery's redacted error remained null. This supersedes the earlier sequence `114` run, where a synthetic hash was incorrectly passed to Telegram as a message ID and the receipt reaction failed.
4. `/new` established the fresh boundary, the following request entered active issue `b2867d3e…`, and the provider showed the acknowledgement followed by the successful final response `tg-receipt-order-live`.
5. The working and final publications each completed in one attempt with no error and reused provider message `417200359:121`, proving that the final response edited the working message in place instead of posting a duplicate.
6. The focused same-second ordering regression passed before the live retest and now asserts the provider-native `chatId:messageId` shape.
This proof supersedes the previously observed same-second race. It does not by itself prove general burst handling across different tasks, multiple chats, or multiple workers.
## Pre-merge local regression evidence
- Telegram edit lifecycle rows now retain the normalized external actor and revalidate the current identity link and Paperclip membership under lock immediately before creating the lifecycle system comment. A deterministic race revokes the actor's link after the original message is admitted; the later edit is filtered, its text is removed from the durable row, and no task comment is created.
- On that pre-merge working tree based on revision `77ad5383e`, the full chat-channel PostgreSQL integration suite passed 183/183 on fresh migrated database `chat_adapters_test_final_20260906_0833`.
- Focused shared tests passed 11/11, focused server tests passed 194/194, and focused UI tests passed 41/41.
- The deterministic browser suite `tests/e2e/chat-adapters-ui.spec.ts` passed 4/4, and shared, database, server, and UI typechecks all passed.
- These deterministic checks support the live continuation fix but do not replace the remaining provider cases.
## Earlier core-smoke evidence
On the older `e5f3917b7` checkpoint, rapid updates `88` and `89` each produced one inbound delivery and one final publication in FIFO order. One Telegram Web client displayed an apparent duplicate, but an independent client, the provider event IDs, and Paperclip's durable records showed only one inbound event and one final publication. That older evidence remains a rendering-artifact diagnosis, not a substitute for the current run.
## Qualification gap
This was not a full Telegram runbook PASS. Private-chat commands, text documents, group privacy-mode operation, forum-topic boundaries, queue ordering, removal/rejoin, reaction add/remove, edits, and the silent-publication boundary now have live evidence, but the following still do not:
- disabled-resource enforcement and linked/unlinked identity governance;
- forged and expired real-provider actions beyond the tested one-shot native confirmation; native rendering, continuation, sibling expiry, accepted-state edit, and exact final delivery now have live evidence;
- audio, video, oversize or malformed media, and download-failure handling;
- flood-control retry, global token revocation, recovery, and credential rotation; and
- the complete cleanup and evidence checklist.
Telegram remains unqualified for stable release until the remaining live scenarios pass on the final release-candidate source.
September 7 evidence update: real photo receipt and return now have live proof in
[the media qualification](2026-09-07-media-live-qualification.md) and
[the native Codex/Luna qualification](2026-09-07-native-runner-chat-qualification.md).
The native run inspected the provider-delivered image and returned the same
bytes as a photo. This does not qualify audio, video, failure handling, or
reuse of an older attachment outside the current wake.

View File

@ -0,0 +1,221 @@
# Discord live qualification result — 2026-09-06
For the reported missing-image failure and its successful September 7 retake,
see [media qualification](2026-09-07-media-live-qualification.md).
For subsequent native Luna PNG+TXT returns and the latest deployment, use the
[September 89 qualification ledger](2026-09-08-chat-queue-and-webhook-repair.md).
The older gap list below is checkpoint-specific: files are no longer wholly
untested, but remaining media boundaries and server 70's normalized-button
denial still need live qualification. Gateway reconnection alone is not a
provider conversation pass.
> **Status: core Discord transport, ordered follow-up bursts, receipt cleanup, and keep-open idle recovery have live proof, but the full DC1DC7 matrix remains unqualified.** Paperclip has verified the dedicated bot identity, Message Content intent, Clawd membership, and a permission-complete text channel against Discord. The later clean-source checkpoint supersedes the intermediate unsolicited-recovery blocker.
## Resumed live setup — 2026-09-07 UTC
The operator entered the existing bot token directly into Paperclip's masked
field; it was not reset, displayed, logged, or copied into this result. The
first connection attempt reached Discord but failed with HTTP 400 / code 50035
because Paperclip called the numeric Get Guild Member route with the literal
`@me`. The scoped repair now uses the already verified Application ID as the
bot user snowflake.
After that repair, the preserved provider token connected successfully on the
working tree based on `f5f31d2e1`. The Paperclip endpoint reached its real
`verifying` state with bot external ID `1546330979860221952` and provider
account/server ID `1457808928258658549`; the UI advanced to **Try Maya E2E in
Discord**. This provider-backed transition proves that the token identifies the
configured Application ID, Message Content intent is enabled, the bot is a
member of Clawd, and at least one text channel grants the complete required
permission set. The scoped fix and this result must be committed and its
automated checks recorded before treating the revision as a release candidate.
This was partial DC1 setup evidence at the time. The linked conversation pass
below supersedes that limitation, while DC2DC7 and the unexercised DC1 cases
remain open.
## First native root checkpoint — 2026-09-07 UTC
In Clawd channel `1457808933082108089`, a real root mention produced the `eyes`
receipt, exactly one native public thread (`1546509943639773244`), exactly one
Paperclip task (`CHA-3`, issue `1976d84b-0bdf-4342-8afa-1a3e5d9be57c`), and one
bound conversation (`123b687c-96d7-4164-bf58-bc95edf2bc8c`). Because the
Eigenjoy Discord principal was unlinked at admission, the turn correctly
published the safe low-trust-isolation refusal in that thread instead of agent
output. The operator then completed the private identity link to the local
Paperclip board; no one-time link or credential is recorded here. A fresh root
must still prove the linked path because linking cannot retroactively change
the trust boundary of the already admitted guest turn.
The live refusal also exposed a receipt-lifecycle defect: the `eyes` reaction
remained on the root after the visible terminal failure. The implementation
had a durable add-only action and never invoked the adapter's idempotent
reaction removal, despite DC4 requiring both add and remove. The scoped repair
stages a Discord-only removal in the same transaction that records the causal
terminal publication, then attempts it under the same credential lease; a
crash or transient provider error resumes from the durable action without
replaying the terminal message. It removes the working receipt rather than
replacing it with a success or failure emoji. The fresh linked turn below
verified that the receipt is now cleared after the terminal reply.
## Successful linked round trip — 2026-09-07 UTC
The live source was the dirty working tree based on `1325329e3`, started at
13:44:55 UTC; this evidence must therefore be repeated on the final clean
release-candidate SHA before release. In Clawd `#general`, the linked Eigenjoy
principal created native thread `1546513811672932372`, exactly one Paperclip
task (`CHA-4`, issue `c65f32f8-a612-4f85-97c5-61bed2de58e2`), and one bound
conversation. Only `#general` was enabled in Paperclip; the other ten discovered
channels were disabled.
An unmentioned follow-up (`1546516684129575123`) in that native thread asked
for the exact text `DISCORD-LIVE-0907-ROUNDTRIP-OK`. Run
`2443fa37-ea1e-436b-a1af-3ad6e58afc51` ran from 13:45:11 to 13:45:17 UTC and
succeeded. Discord reply `1546516692031504485` contained the exact marker, and
the working receipt was cleared. This proves a real linked root boundary,
native thread reuse for an unmentioned follow-up, task/run execution, exact
final presentation, and terminal receipt cleanup through the configured bot.
The endpoint `af23c9d0-8d7f-495c-a45c-ba9ab1ee9686` was active and setup-complete
in the UI. After the successful reply, however, generic task recovery spawned
an unsolicited additional run (`6a3e0303-b5f2-4e32-8e36-790a892f07b6`). That is
not acceptable production behavior: a completed Discord turn must not trigger
new agent work without a new admitted user event. The recovery fix and a clean
live rerun are still required. No provider credential, identity-link secret, or
private callback value is recorded here.
### Rapid follow-up burst checkpoint
A later three-message burst on the same `CHA-4` Discord thread persisted all
three inbound messages in order. The first message started run
`4e42c03d-2c80-45ed-bb99-84a5b7c94c02`; the second and third messages were
coalesced into one deferred wake and then run
`2ae3cb6e-b33c-4c93-a029-02916210d142`. The provider-visible result was two
replies for the three inputs: an initial `ALPHA` acknowledgement, followed by
the combined exact `ALPHA BETA` result. The first turn took approximately 82.6
seconds and the second approximately 50.1 seconds, so this is ordered-delivery
and coalescing evidence, not an instant-response claim. All three working
receipts were cleared when their causal runs reached terminal publication.
The receipt-retirement audit confirms why the coalesced case is lossless:
deferred wake merging preserves the ordered `wakeCommentIds` set, promotion
copies that set to the successor run, and terminal Discord publication selects
receipt actions for every exact linked inbound comment in that run. It does not
clear unrelated or later thread receipts. No additional automatic recovery run
was present in the 13:58 UTC check, but `CHA-4` had been marked done by then;
that observation does not independently prove the new in-progress recovery
guard.
### Clean keep-open recovery qualification — 2026-09-07, 13:59 UTC
This checkpoint supersedes the pending keep-open retest and the earlier
unsolicited-recovery blocker. On clean source revision `5bd9c0d55`, an
unmentioned follow-up in native thread `1546513811672932372` left CHA-4
deliberately `in_progress` and requested exactly `DISCORD-IDLE-WAIT-OK`. Run
`f8c9dbe2-7e94-469d-8345-717eb7dad1bf` ran from `13:59:31.087Z` through
`13:59:38.234Z` and succeeded. Discord
[reply 1546520298793468036](https://discord.com/channels/1457808928258658549/1546513811672932372/1546520298793468036)
contained exactly that marker.
CHA-4 remained `in_progress` with its conversation active for more than eight
minutes after the terminal reply, with no additional run. This proves the
repaired idle-chat boundary live: an open conversation waits for new provider
input instead of being reclassified as stranded work.
After the latest setup-edge changes, the full chat integration suite passed
**258/258** and the combined process-recovery/status-payload suite passed
**135/135**, both with zero skips. The deterministic browser suite had passed
**5/5** on clean revision `5bd9c0d55`, but has not yet been rerun after the
latest setup-edge/UI changes; the current working tree is therefore not being
claimed browser-green here.
## Historical live-attempt checkpoint — superseded above
The authorized provider target is the `Clawd` Discord server, numeric ID `1457808928258658549`, using the user's Eigenjoy account. The latest in-app-browser attempt reached Discord's login/QR flow in both the Developer Portal and server tabs. It did not reach application creation or expose a bot token. Login completion is therefore the current external gate.
### Release decision at this checkpoint
At this historical checkpoint, Discord remained blocked before provider setup.
The resumed setup evidence above supersedes that gate while preserving this
record of what had not yet been tested.
Once the authenticated session is available, the required path is:
1. create a dedicated Discord application and bot for the immutable Paperclip agent;
2. enable Message Content Intent and enter only Application ID, Server ID, and the write-only bot token in Paperclip;
3. inspect the generated OAuth URL for exactly the `bot` scope and permission integer `309237763136`, with the Clawd server pinned and server selection disabled;
4. install the bot in Clawd, connect it in Paperclip, enable only the intended test channel, and execute DC1DC7 from the browser runbook.
There is no managed bot-provisioning path, public webhook URL, interactions public key, slash command, or endpoint delivery choice in the current product.
No bot token, cookie, password, MFA value, or one-time identity-link URL is recorded here.
## Implemented behavior and remaining live proof
The current native Discord implementation includes:
- a long-lived Gateway runtime with bounded reconnect/retry behavior and full provider `retry_after` waits rather than an application-level 60-second cap;
- immutable application identity, including a database uniqueness constraint that prevents one Discord Application ID from backing multiple active Paperclip agent endpoints even across different servers;
- server and effective-channel-permission verification, channel discovery, a Paperclip allowlist, and a separate direct-message reach switch;
- one root mention to one Discord public thread and one Paperclip task, with thread replies serialized onto that task and DMs isolated into linear task generations;
- endpoint, resource, principal, and root-message preflight before provider-thread creation; denied roots retain only a payload-redacted filtered audit and create no provider thread or Paperclip work;
- crash-safe root activation: an allowed root persists a provisional receipt before the provider POST, then recovery idempotently creates or reuses the thread and treats Discord error `160004` as an existing-thread reconciliation;
- explicit missing-root filtering plus retryable ambiguous transport and authentication failures, so uncertainty is neither silently discarded nor misreported as a completed binding;
- durable message links, endpoint-generation fencing, reaction hydration, edit/delete lifecycle handling, embeds/buttons, and bounded Discord-CDN attachment ingestion;
- a fail-fast compatibility marker and required-method contract for the pinned SDK patch;
- 25-second REST deadlines and structured preservation of Discord 401, 403, 404, 429, and `retry_after` failures without copying raw provider bodies, user content, credentials, interaction tokens, or derived thread names into exceptions or logs; and
- the shared safe-publication, ambiguous-delivery, identity, permission, audit, and internal-content boundaries used by the other providers.
The linked run above now demonstrates the primary root, thread-reuse, exact
final-response, and receipt-cleanup path. The remaining items are still
code-level claims until the corresponding DC cases exercise them against the
real provider.
## Historical code-audit status before the linked live run
The final hardening removed the code-level release blockers found in the root-activation and lifecycle audit: denied roots no longer create an inert provider thread; a crash between Discord thread creation and Paperclip binding now resumes through the persisted provisional receipt and idempotent reconciliation; provider response bodies and callback errors no longer disclose content or credentials through diagnostics; retry scheduling honors long Discord backoff windows; reconnect now has a distinct, payload-redacted activity action; and Discord `50001`/`50013` destination permission failures disable only the affected resource rather than putting the whole endpoint into attention. True token/app authentication failures and unrelated authorization errors remain endpoint-wide. The compatibility marker, required patched-method checks, clean patch application against the pristine package, and 25-second REST boundary make SDK drift and stalled provider calls fail visibly rather than weakening those guarantees. Per repository policy, CI owns `pnpm-lock.yaml`; its PR workflow regenerates a lockfile artifact from the manifests before running the frozen install.
At that checkpoint, no code-audit blocker was recorded and none of the behavior
had yet been observed against the real provider account/server. The linked live
run above supersedes the latter statement and exposed the unsolicited recovery
run as a current blocker. Live proof must still cover denied-root silence,
provisional recovery, existing-thread reconciliation, files/interactions,
Gateway reconnect, rate limits, token rotation, and the visible management
surfaces. The adapter patch remains version-sensitive; any dependency update
requires the compatibility and provider contracts to rerun.
## Local regression evidence
- Final Discord implementation revision: `83018c688` (log-redaction hardening); parent merge revision: `da8f83d6c9befe7bf958f6d9cf12a95fc7e59e88`.
- Before the final merge, Discord-focused adapter/runtime tests passed 41/41.
- Before the final merge, fresh PostgreSQL Discord integration tests passed 2/2, including concurrent identity claims.
- All migrations and migration-safety checks passed, including global Discord Application ID uniqueness.
- On the parent merge, the full chat-channel PostgreSQL integration suite passed 188/188 on a fresh migrated database, merge-conflict-focused server tests passed 355/355, and the deterministic five-provider browser suite passed 5/5.
- On the Discord implementation revision, the 42-test Discord adapter/runtime subset and 34-test Discord/OpenAPI/UI contract subset passed, along with server/UI typechecks, token gates, and both working-tree checks.
- The Discord patch applied cleanly to a pristine `@chat-adapter/discord@4.39.0` package, and the patched distribution passed syntax and compatibility checks. CI will regenerate the PR lockfile artifact before its frozen install, as required by repository policy.
- The post-audit Discord adapter/runtime subset passed 48/48, including raw-provider-body and callback-error redaction plus a 120-second `retry_after` contract; the focused reconnect/removal PostgreSQL scenario also passed and proved secret replacement, old-secret retirement, runtime replacement, identity/history/access preservation, redacted reconnect activity, and final Paperclip credential cleanup. Endpoint removal does not uninstall the bot from the Discord server or delete its Developer Portal application; those remain separate provider-side cleanup steps.
- The final Discord permission classifier/adapter subset passed 49/49, and its database-backed publication regression proved that `50013` cancels only the affected publication/resource while the endpoint remains active. The final combined working tree then passed 193/193 chat-channel integration tests on fresh migrated database `chat_adapters_test_final_20260906_1257`, 111/111 focused runtime/error/privacy tests, all package typechecks, token gates, and the deterministic five-provider browser suite.
This evidence supports implementation integrity. Provider installation,
Message Content intent, effective `#general` permission, a native root/thread,
linked identity, exact final reply, and receipt cleanup now also have live
proof. It does not replace the remaining Gateway-reconnect, rate-limit,
restart, file, action, negative-reach, token-rotation, and cleanup cases.
## Qualification gap at the September 7 checkpoint
Provider credential validation, Message Content intent, Clawd membership,
`#general` enablement, root-thread creation, a linked unmentioned follow-up,
exact final presentation, and working-receipt removal now have live proof. The
unsolicited post-completion recovery run was repaired, and the clean keep-open
checkpoint above proves the fix against the real provider.
The live three-message burst now proves ordered persistence, deferred coalescing,
two causal runs, combined final presentation, and cleanup of every causal
receipt, with the observed 82.6-second and 50.1-second turn latency recorded
above. Disabled-channel silence, denied-user behavior, provisional recovery,
existing-thread reconciliation, duplicate/reconnect fencing, edits/deletes,
embeds/actions, inbound/outbound files, DMs, ambiguous sends, token rotation,
intent revocation, provider links, management surfaces, and cleanup remain
open. Discord remains unqualified for stable release until the remaining DC
cases pass on one final clean release-candidate SHA.

View File

@ -0,0 +1,734 @@
# Chat adapters live qualification addendum — 2026-09-06
This addendum records the qualification state observed on 2026-09-06. It is
deliberately narrower than the provider runbooks: automated proof and live
provider proof are reported separately, and an account page being reachable is
not counted as a successful end-to-end conversation.
## Reliability work completed in this pass
- Provider-visible mutations are fenced against credential rotation, pause,
reconnect, and removal with durable credential leases and generation/ref
checks.
- Outbound sends use short durable claims around provider I/O. A response lost
after provider acceptance is quarantined as `delivery_unknown`; it is not
replayed automatically.
- Explicit duplicate-risk retries are audited and single-owner. Slack
slash-command roots persist a provider-confirmed phase before the separate
Paperclip task admission phase, so crash recovery cannot post a second root.
- Slack slash-command authorization and destination reach are snapshotted in a
transaction that releases its row locks before provider I/O. That snapshot
authorizes only the Slack root send. The later Paperclip task admission is a
separate mutation that rechecks current endpoint reach, resource state,
identity link, membership, and guest sponsorship after any crash or restart.
Reclaimed admission workers carry a durable ownership token so an obsolete
worker cannot settle the successor's attempt. A recovered command cannot
reactivate a disabled setup destination, including when its durable envelope
was written by an older version. Rejected, unapplied deliveries retain only
identifiers needed for deduplication and filtering diagnostics, not message
text or principal profiles.
- Receipt reactions use their own idempotent outbox. A Slack retry that reports
`already_reacted` settles successfully, while rate limits retain their full
provider retry interval.
- Inbound turns are processed in durable provider order under a renewable
conversation lease. Lifecycle changes and credential changes fence stale
runtimes instead of allowing them to commit later work.
- Run completion waits for the runner's presentation decision and suppresses a
generic completion when an explicitly authorized final response exists. A
provisional same-run final comment can be upgraded to the externally visible
response without creating a duplicate comment.
- GitHub verifies webhook signatures and current installation/repository reach
before retaining a bounded recovery payload. Durable claims survive process
restarts, fence credential changes, and redact terminal payloads. A manual
provider redelivery can rearm a terminal failure only for the identical event
and body digest; lifetime attempt ownership is not reset. Both GitHub mention
forms work, while setup instructions show the App's bare slug.
- Discord responses exceeding the provider's rendered message limit are sent
losslessly as a Markdown attachment. Only the safe external response is used;
internal reasoning and logs are not included.
- Telegram can finish an already-queued second turn after natural task
completion, but cannot cross an explicit `/new` or `/close` boundary. Teams
thread decoding validates canonical encoding before interpreting legacy IDs.
- Invalid publication payloads fail individually instead of poisoning the
global queue. Transient preparation failures use bounded backoff, and the
same drain can continue to a healthy publication behind the failed row.
- Provider-confirmed Slack admissions on paused or attention endpoints remain
parked without occupying the active worker page. They become eligible again
after the endpoint is repaired or resumed; active endpoints can keep moving.
- Dual-purpose connectors keep their chat setup separate from tool credentials.
The tool connection flow excludes chat-only methods from selection,
recommendations, and submission, and agent-facing connection intents expose
only tool methods. GitHub's personal-token fallback therefore does not ask
for chat App credentials or strand the user on another chooser. A tool-access
request for a chat-only provider is rejected.
## Automated checkpoint
- Full chat integration suite: 240/240 passed on a newly created PostgreSQL
database both before and after merging `origin/master` at `856813ba3`.
The post-merge database is `chat_adapters_test_20260906_full2480`; the run
includes all five provider fixtures. Provider transport is simulated.
- Focused server/API/UI checks: 247/247 passed across 21 files. Post-merge safe
publication/projection checks also passed 22/22.
- The upstream runner slice passed 85/85. Tool-setup/catalog/shared-definition
regression checks passed 127/127 (106 UI and 21 shared assertions).
- The connection-intent service suite passed 8/8, with all seven
embedded-PostgreSQL cases executed rather than skipped.
- Deterministic chat-adapter browser checks: 5/5 passed after the merge.
Provider API responses are mocked, so this is UI regression evidence only.
- Direct shared, server, and UI TypeScript checks passed after the merge.
- The post-merge UI production build passed, with existing CSS/font and
chunk-size warnings.
- `git diff --check` and UI token gates passed. The lockfile is the exact
upstream CI-owned artifact; no hand-authored lockfile changes are included.
- Earlier full-suite hangs were traced to synthetic 90-second test leases left
behind by fault-injection cases; those fixtures now clean up only after
verifying the ownership fence. Another run was interrupted by macOS sleep.
The passing full run kept the machine awake for the test process and used no
temporary diagnostic instrumentation.
- The repository-wide `pnpm test:run` previously failed on unrelated runtime
and test-harness issues. Repository-wide tests, typecheck, and build are not
claimed green; the evidence here is the named focused verification.
## Live provider evidence and remaining gates
### Slack
- The existing Slack app is `maya-paperclip` (`A0C0NSMSA5N`).
- A historical native-question thread was visually inspected. The question was
answered, but the visible terminal reply was the generic “Maya completed this
turn.” This is a real quality failure, not a successful qualification.
- That historical fixture lived in a temporary database that no longer exists,
so its comment/run/publication provenance cannot be reconstructed honestly.
- The persistent isolated Paperclip instance on port 3103 currently has a fresh
draft endpoint and no conversations or activity. It therefore provides no
fresh Slack round-trip proof yet.
- Slack's **Show** control for the Signing Secret did not respond after the
documented fresh-tab retry. The Mac session then locked. A fresh round trip
still requires the signed-in operator to reveal/copy that existing app secret
(or rotate it deliberately), reconnect the draft, and send a new native
question through completion. The new run must verify the exact final text,
reaction behavior, one-thread/one-task binding, audit rows, and absence of
duplicate provider messages.
### GitHub
- A GitHub App named `Paperclip Maya E2E 0906` was created with App ID `4853886`.
- It is not installed, its private key has not been generated, and the webhook
save against the temporary public callback was blocked by the browser tool's
external-write review. The signed-in GitHub confirmation had already been
completed; this was not a provider login or MFA gate. No issue/PR comment
round trip has therefore been qualified.
### Discord
- The intended target remains the `Clawd` server (`1457808928258658549`) and
channel `1457808933082108089`.
- The saved account password was rejected before the provider MFA step, so a
Discord application/bot was not created or installed. There is no live
Discord message proof yet.
### Microsoft Teams
- The available login reaches personal Teams, but no Microsoft 365 tenant/admin
context is available for Bot Framework registration, consent, packaging, and
installation. Personal Teams login is not evidence that the Teams adapter
works.
### Telegram
- Telegram login/QR access was completed earlier, but no fresh bot endpoint and
complete message/reaction/attachment round trip was recorded against the
persistent 3103 fixture in this pass. Telegram remains unqualified live.
## Release interpretation
The hardening and automated checks materially improve crash recovery, ordering,
credential fencing, and auditability, but live qualification is not complete.
Do not describe any of the five providers as production-qualified until a fresh
provider event reaches the persistent isolated instance and its provider UI,
Paperclip task/comment/run, outbox state, reactions/actions, and terminal reply
have all been checked together.
## Resumed qualification — 2026-09-07 UTC
This checkpoint supersedes the setup gates above without changing the historical
observations or claiming a completed provider conversation.
### GitHub
- The App now has two registered private-key fingerprints. Neither private PEM
was available in the local Downloads directory, and GitHub's settings page
offered no download for the registered keys. No replacement key was generated
or existing key deleted by the agent in this resumed pass. The operator must
recover the original browser download or deliberately generate and retain a
replacement; the PEM must stay out of chat and logs.
- The old temporary callback hostname no longer resolved. GitHub's delivery
detail explicitly reported a failure to connect to the host. The webhook-only
tunnel was replaced, the App callback was updated, and the setup ping was
redelivered once. Paperclip verified its signature at
`2026-09-07T01:33:42.242Z`. Delivery ID:
`193f08a6-aa5b-11f1-8d07-d6d11e41dcde`.
- The public tunnel forwards only provider webhook POSTs; a public request to
`/api/health` returned 404. The local-trusted board API was not exposed.
- The provider UI was checked directly: Issues and Pull requests are read/write,
Metadata is read-only, and only Issue comment and Pull request review comment
are selected. GitHub's automatic installation events need no checkbox.
A new integration regression accepts `/app.events` containing only the two
selectable events.
- The App remains uninstalled. A signed ping proves webhook delivery and
signature verification only, not repository reach or an issue/PR round trip.
#### GitHub live checkpoint — 2026-09-07 13:00 UTC
This later checkpoint supersedes the uninstalled/no-private-key state above.
The operator authorized a newly downloaded private key, and it was imported
through Paperclip's masked file control without reading, displaying, or
recording its contents. Paperclip verified App `4853886`, discovered the single
installation `159668881`, and reconciled exactly the two approved private test
repositories.
The first real setup issue is
[`cryppadotta/paperclip-chat-e2e-enabled#1`](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/1).
Root comment `5570993571` produced exactly one Paperclip task, `CHA-1`
(`07a57128-20ef-4905-aa85-3bbcb4f2769e`), and one external conversation
(`6a6d6bfa-4b21-45d7-87b3-9a8885449c5a`). GitHub displayed one eyes reaction
and bot reply `5570994445`. The reply correctly failed closed because the turn
belonged to an unlinked external guest and isolated guest execution was not
available. This proves signed issue-comment ingress, repository admission,
one-issue/one-task binding, reaction delivery, and safe containment; it does not
prove a successful agent response.
The endpoint remains `verifying`. Paperclip opened the private confirmation
flow for `cryppadotta` to the signed-in board account, but the user-controlled
identity confirmation is still pending. No confirmation URL or token was
recorded. The retained `CHA-1` task remains low-trust; after confirmation, a
fresh GitHub issue is required to qualify the linked path and the unmentioned
follow-up response.
The current Cloudflare webhook-only receiver remains in service for this test.
The host's Tailscale connection is healthy, but Funnel is disabled for the
tailnet and awaits administrator enablement before it can replace that receiver.
The GitHub App homepage still points to the earlier temporary public host; that
is a minor setup-polish defect, while the signed webhook callback itself remains
the operative ingress route.
### Discord
- The user completed App creation. `Paperclip Maya E2E` now exists under
`eigenjoy` with App ID `1546330979860221952`, and its Bot settings are reachable.
- Paperclip's draft has that Application ID and the requested Clawd server ID.
The generated bot-only installation link locks the server selection to
`1457808928258658549`; no unrelated server is targeted.
- The installation flow requires a separate main-Discord login despite the
Developer Portal session. That login is open for the operator. Message Content
Intent, deliberate token generation, and server installation still require
completion. No native Discord message has been qualified in this pass.
### Telegram credential incident and containment
- The signed-in Telegram browser reached the official BotFather conversation
for the existing test bot `@MayaPaperclipQA0905Bot`.
- The agent incorrectly copied a message's concatenated DOM text, appending two
timestamp digits to the token. Paperclip rejected the resulting setup request.
The HTTP failure logger then recorded the raw submitted credential object.
This was both an agent copy error and a real product credential-redaction bug.
- The isolated live server was stopped, the form and in-memory copied value
cleared, and the credential object removed from the local test log. A
metadata-only scan of the relevant local logs found no remaining raw
credential objects or Telegram-token-shaped strings. This local cleanup does
not revoke the token or erase previously emitted diagnostic output.
- The affected bot token must be rotated in BotFather before further live use.
No new token should be sent through chat or printed during qualification.
- The fix redacts whole credential envelopes plus provider-specific camel/snake
case fields. It also redacts Telegram's reusable webhook-secret header on
successful requests. Secret-sensitive setup errors are replaced before local
logging, telemetry, and crash reporting; provider-controlled error names are
not trusted. Synthetic serialized HTTP regressions cover mounted API routes,
422/500 failures, setup-secret failures, and successful webhook headers.
- The failure revealed another usability defect: the toast disappeared and left
no explanation in the form. Setup errors are now persistent, redact submitted
values, preserve masked inputs, and clear on successful retry. The deterministic
browser suite exercises this fail/retry path, not a real Telegram credential.
- The safety fix was committed and pushed as `80eaf11ad`, then the isolated
instance was restarted on that commit. A deliberately invalid synthetic token
was submitted through the actual in-app browser form. Telegram rejected it,
the persistent error remained visible, and the input stayed masked. A
metadata-only check of the new server log confirmed the canary was absent and
the credential envelope was redacted. The synthetic value was then cleared.
This verifies the real failure path, not bot authentication or a conversation.
### Cross-provider quality work
- Long structured Telegram replies now preserve Markdown as a native `.md`
attachment when splitting would damage fences, lists, links, or other block
structure. Plain prose still uses readable, lossless chunks. Replacement of a
progress message and the attachment send use separate durable publication rows
with ordered handoff. This was committed and pushed as `0ebb90145`.
- The Teams manifest now includes the required `webApplicationInfo` association
for resource-specific consent. This does not add SSO, delegated Graph access,
or a requirement to register an Entra Application ID URI. Live Teams still
needs an eligible Microsoft 365 tenant and administrative setup.
- Slack still needs its existing app credentials connected to the persistent
draft and a fresh completed conversation. Historical generic completion text
is still treated as a failed quality observation, not release proof.
### Final automated checkpoint for this resumed pass
- Full chat integration: **242/242 passed**, no skips, on the fresh migrated
database `chat_adapters_test_20260907_synchronized_final`. This includes the
manually-created GitHub App fixture and structured Telegram reply transport.
- An intermediate run passed 241 tests and failed one lifecycle-recovery
assertion. The fixture observed a processed row before its background drain
had released the conversation lease. It now waits for that actual lease
boundary before injecting the next transaction failure. Exact attempt/state
assertions and timeouts are unchanged; no production behavior was altered to
make the fixture pass. The final full run above includes that correction.
- Focused publication, adapter, setup UI, error handling, and privacy checks:
**120 passed, 0 failed**. Four existing real-Sentry-SDK checks were skipped
because the SDK could not be loaded in this checkout. Mocked crash-sink input
and actual serialized HTTP canary tests ran and passed.
- Deterministic provider browser flows: **5/5 passed**, including persistent
failure feedback, credential-safe retry, and the Teams consent manifest.
These mock provider success; they are not live provider qualification.
- Shared, server, and UI TypeScript checks passed. UI production build passed
with the existing bundling warnings. Token gates and `git diff --check` passed;
`pnpm-lock.yaml` remains untouched.
- Independent read-only privacy review confirmed the concrete credential
envelope, Telegram header, provider error-name, and HTTP response leaks were
covered. Review used synthetic canaries and inspected no real credential
stores. A pre-existing arbitrary credential absent from a submitted request
cannot be identified by exact-value matching in curated 4xx errors; provider
service error redaction remains the upstream boundary for those values.
No provider is promoted to production-qualified by this checkpoint. The signed
GitHub ping and real invalid-token error path are useful live evidence, but all
five channels still need fresh completed, provider-visible conversations on the
persistent fixture once the remaining credential and tenant gates are resolved.
### Follow-on Slack credential exposure — 2026-09-07 UTC
- A fresh signed-in Slack App management session made the existing Signing
Secret reveal control respond. The agent copied that value in memory without
printing it, but did not submit it to Paperclip.
- Navigating to OAuth & Permissions briefly showed a provider load error. The
agent then requested a full diagnostic DOM snapshot; before it ran, the page
finished loading and exposed the Bot User OAuth Token in tool output. This is
an agent qualification-procedure failure, not a Paperclip logger regression.
- No Slack credential was submitted to the isolated Paperclip instance. The
copied signing-secret variable was cleared. The bot token shown in that
snapshot must be revoked and replaced before further use. Do not treat local
log cleanup or hiding the provider field as revocation.
- The runbook now forbids full snapshots, whole-page text, and screenshots on
secret-bearing provider surfaces even during loading/error states. Only
explicit nonsecret labels and control metadata may be inspected there; secret
entry remains an operator handoff into Paperclip's masked controls.
- The operator can revoke the affected `maya-paperclip` OAuth token and repeat
the provider installation flow to obtain a replacement. Revocation can remove
the bot's channel memberships, so the authorized test channel must be checked
and the bot reinvited afterward. See Slack's
[token-revocation contract](https://docs.slack.dev/reference/methods/auth.revoke).
### Parallel hardening and operator handoff — 2026-09-07 UTC
- Slack now declares the native agent surface, `assistant:write`, and
`agent_session_stopped`. Session indicators have a durable, idempotent retry
lane independent of message delivery. A delayed status retry recomputes the
current published state and cannot revive a cancelled run's working status.
Revision, owner, and selected-row fences prevent stale workers from changing
a newer result. Working indicators refresh before Slack's one-hour timeout.
- Native Slack Stop is authenticated and durably recorded before webhook
acknowledgement. It binds the original conversation generation and exact
run or queued wake, rechecks the linked user's current authority and reach,
and uses provider event time to exclude later work. Cancellation receipts
must reflect the authoritative run outcome, including a run that finished
before cancellation won the race.
- Discord Gateway component acknowledgement now follows durable Paperclip
admission. Denied actions are durably audited without a success ACK, and
admission retries respect Discord's response deadline. Partial message edits
retry their fetch through the same classified provider retry path.
- Teams no longer caches user/activity metadata or performs member/Graph
lookups before Paperclip admission. Accepted metadata writes are awaited;
foreign, missing, conflicting-tenant, and targeted activities fail closed.
Setup corrects `groupChat`, exposes implemented mobile commands, and explains
that the requested RSC grants deliver every message in an installed team or
group chat, while Paperclip's own admission rules constrain retention/work.
- Browser access was initially blocked by the locked Mac and later recovered.
Safe GitHub App inspection still showed two generated-key records dated
`2026-09-07T01:26:23Z` and `2026-09-07T01:28:06Z`. A filename-only Downloads
check found no PEM for `paperclip-maya-e2e-0906`; no key contents were read.
GitHub stores only the public portion, so a missing private-key download
cannot be reconstructed from that page. No extra key was generated or deleted
during this inspection.
- The operator reported adding Paperclip Maya E2E to Discord. The in-app
channel check redirected to an expired Eigenjoy login, so server membership
is operator-reported, not independently verified. Paperclip's resumed Discord
form has Application ID `1546330979860221952` and Clawd server ID
`1457808928258658549` filled in; the bot-token password field remains empty.
The operator must enter the token in that masked field, never in this report
or the conversation. Server installation alone does not configure Paperclip.
This remains hardening plus partial setup evidence, not a live round-trip
qualification. Fresh provider-visible conversations are still required.
#### Verified parallel checkpoint
- Full chat integration: **249/249 passed**, no skips, on the fresh migrated
database `chat_adapters_test_20260907_parallel_final`. This includes the
exact queued-wakeup-to-run Stop race, late-event and guest denial, status
retry/restart/stale-worker fencing, unsupported/permanent-error termination,
GitHub and Discord question continuations, Discord FIFO, and Teams denied
callback metadata boundaries.
- Focused helper, runtime, adapter, publication, OpenAPI, UI contract, and shared
catalog tests: **159/159 passed**, no skips.
- Deterministic browser flows: **5/5 passed** on the final source tree. An
earlier isolated server boot timed out; the subsequent complete run passed
in 27.1 seconds. These tests mock provider interactions, not live accounts.
- Shared/server/UI TypeScript checks, UI production build, token gates (949
files), and `git diff --check` passed. Existing UI bundle-size and mixed-import
warnings remain. The broad workspace test suite was not rerun or claimed
green; its previously recorded unrelated failures remain outside this proof.
- Final fetch confirmed `origin/master` at `856813ba3` is already an ancestor
of the working branch. No rebase was necessary, no other worktree was used,
no PR was changed, and `pnpm-lock.yaml` remains untouched.
- Unsupported Slack session status now settles until new conversation activity
restages it, rather than polling completed threads forever. Definite
permission/destination failures are separately visible in Activity and do
not resend message content.
- Teams reaction/action/modal metadata recording was moved behind the actual
authorization boundary. The regression checks both rejected callbacks with
a valid route and accepted callbacks with the same route. Admitted lifecycle
changes retain regional reply-route refresh without retaining user metadata.
The operator-reported Discord install still requires a bot token entered into
Paperclip and a restored Eigenjoy browser session for live provider proof.
GitHub still needs its private PEM; Slack and Telegram need the previously
documented exposed tokens rotated; Teams needs an eligible tenant/admin setup.
None of these gates is represented as a successful live conversation.
#### Test ingress renewed after the verified-code restart
- The isolated server was restarted with verified code `f2724d8f2`; its private
health response reports that commit and ready startup recovery.
- The old quick tunnel expired (`Unauthorized: Tunnel not found`) while its
process kept reconnecting. It was replaced with
`https://doctor-files-whole-concepts.trycloudflare.com`. This supersedes the
earlier `tile-daily-angle-rather` hostname for the live fixture.
- The existing webhook-only proxy still rejects the public board health and
company API paths with **404**. A recognized unsigned GitHub `ping` reaches
Paperclip and returns **401**. No local-trusted board/API was exposed.
- GitHub App `paperclip-maya-e2e-0906` now has its existing webhook URL updated
to the replacement host, with the same endpoint public ID and secret. The
provider displayed its successful saved-app notice; no credential was read,
generated, rotated, or deleted during that URL update.
- The GitHub Paperclip form has App ID `4853886` filled in and still needs the
operator's PEM. The Discord form retains its known application/server IDs and
still needs the bot token. This does not establish a successful agent run.
### Webhook/board separation and credential-entry polish — 2026-09-07 UTC
- A live-readiness audit found that a webhook-only tunnel was also being used
as the board origin. That produced valid-looking Paperclip links whose host
intentionally returned 404. `PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL` now controls
only provider callback URLs; the board origin still controls authentication,
identity confirmation, task links, and trusted hosts. Invalid explicit ingress
URLs refuse startup without echoing their value. Local/private task links are
omitted with neutral instructions, not redirected to ingress or displayed as
`[link removed]`. Config-file-only board URLs work for question cards too.
- GitHub setup now imports a downloaded PEM directly into the in-memory
credential field, with a 64-KiB limit, persistent safe errors, and revision
fencing against slower file reads, later paste, and unmount/provider changes.
Connect is disabled during import. A real deterministic browser check caught
the previous CSS-masked textarea exposing its contents as page text. The
default is now a password input; an actual multiline textarea exists only
during explicit reveal. Both pasted and imported PEMs reach configure
byte-for-byte. Only synthetic keys were involved in this test.
- Discord component denials now send one fixed private remediation after the
denial is durable and before the acknowledgement deadline. Duplicate accepted
callbacks still acknowledge normally; late denials do not respond; reply
failure is not retried or logged with provider content.
- The first combined integration run was 250/251. Its Telegram helper raced a
concurrently scheduled terminal-card drain: the requested next question was
subsequently published once, nine milliseconds after creation, with one
attempt and no delivery error. The helper now waits for its own durable
publication state; no production retry/ordering rule or timeout was weakened.
Final combined verification for these changes:
- **251/251** full chat integration tests, zero skips, on fresh database
`chat_adapters_test_20260907_origin_verified`.
- **83/83** focused server/config/provider/link tests and **14/14** focused UI
tests; **5/5** deterministic browser cases, including the actual file chooser,
imported/pasted credential payloads, reveal/hide, and error recovery.
- Shared, server, and UI typechecks passed. Design token gates and diff checks
passed. The broad workspace suite was not rerun and is not claimed green.
- Reports are retained under `.paperclip-runtime/chat-adapters-live/` as
`origin-verified-integration.json`, `origin-final-unit.json`,
`origin-verified-ui-unit.json`, and `origin-verified-browser.log`.
These checks do not replace live provider qualification. Discord still needs a
bot token entered into Paperclip and a renewed provider login; GitHub needs its
PEM and repository installation. Slack's signed-in OAuth page is reachable but
its exposed test token still requires replacement and write-only entry. Telegram
and Teams retain their previously documented rotation and tenant gates.
Runtime checkpoint after commit `f535dde54`:
- The combined fixes were committed and pushed to `codex/chat-adapters`; the UI
production build also passed (existing chunk-size warnings only).
- The isolated 3103 server reports `f535dde54` and ready startup recovery. Its
board/auth origin is `http://127.0.0.1:3103`; only
`PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL` uses the current Cloudflare ingress.
- GitHub setup still advertises the exact existing public webhook path. Public
health and company API checks remain **404**; an unsigned recognized GitHub
`ping` remains **401**. No board trust or exposure was broadened.
- The in-app GitHub form was checked without reading credentials: its default
key control is `type=password`, no plaintext textarea is mounted, and
**Choose .pem file** is present. Both provider forms still have empty secret
fields; the known GitHub App ID and Discord application/server IDs were
filled again after the development reload. The setup tabs remain available
for the operator's write-only credential handoff.
### GitHub private fixtures and installation completed — 2026-09-07 UTC
The signed-in in-app browser completed the remaining pre-credential setup:
- Created private, disposable repositories
[`cryppadotta/paperclip-chat-e2e-enabled`](https://github.com/cryppadotta/paperclip-chat-e2e-enabled)
(ID `1359763399`) and
[`cryppadotta/paperclip-chat-e2e-disabled`](https://github.com/cryppadotta/paperclip-chat-e2e-disabled)
(ID `1359763710`). Both contain only their initial README; no production data,
existing repository contents, or generated agent work was added. They are kept
for the pending positive/negative reach tests, not deleted during setup.
- Installed the existing **Paperclip Maya E2E 0906** App on that account as
[installation `159668881`](https://github.com/settings/installations/159668881).
The resulting installation settings visibly retained **Only select
repositories**, with remove controls for exactly the two new fixtures.
Permissions are Metadata read, Issues read/write, and Pull requests read/write.
No existing repositories or all-repositories access were granted.
- The current ingress received a GitHub webhook and returned **200** at
`2026-09-07T04:47:40Z`. Paperclip remains draft and disabled with zero endpoint
resources/conversations, null bot/installation identity, and the earlier signed
ping timestamp unchanged. This is the intended pre-PEM boundary: draft
endpoints accept only setup ping processing; installation events are ignored
without a retained body or new ingress action. The installation will be
discovered authoritatively through GitHub's API during credential configure.
The 200 alone is not proof of authenticated installation ingestion or a chat.
- GitHub's private PEM remains absent from the masked setup field. No additional
private key was created or read. Discord's developer page was rechecked and
shows **Choose an account** / **Please log in again**; its Paperclip token field
is still empty. The parallel audit found no pre-credential live path remaining
for Slack, Telegram, or Teams beyond their documented human-controlled gates.
This advances GitHub setup only. A real issue/PR message, agent run, reply,
reaction, question continuation, and the recovery/governance matrix remain
unqualified until the App PEM is entered and Paperclip connects.
The corresponding pre-PEM installation regression and the complete chat
integration suite passed **252/252**, zero skips, on fresh database
`chat_adapters_test_20260907_github_install_draft`; report:
`.paperclip-runtime/chat-adapters-live/github-install-draft-integration.json`.
Only the regression and evidence documentation changed in this checkpoint;
the running, previously browser-qualified implementation remains `f535dde54`.
### Discord connection repair and GitHub credential qualification — 2026-09-07
The user-reported Discord **Invalid Form Body** failure was a real request-shape
defect: guild-member lookup used `@me` where Discord requires a numeric user ID.
The corrected request uses the already-verified bot ID. Live setup then succeeded
with the existing token and reached **Try Maya E2E in Discord**; no token reset
was needed. The separate Discord chat session still requires Eigenjoy login, so
native message/thread/run qualification has not advanced beyond connection.
GitHub accepted the user-authorized PEM import through Paperclip's file chooser.
Its live issue mention created CHA-1, received a receipt reaction, and received
the expected guest-isolation refusal rather than an agent answer. The private
identity confirmation for `cryppadotta` to the local Board account is staged for
the user; that permission grant has not been confirmed. Recovery copy now
correctly explains that an administrator creates the private identity link.
Tailscale is connected, but Funnel requires tailnet enablement. The pending
request targets only the webhook-only proxy on port 3104 through HTTPS port
10000; existing tailnet-only routes are unchanged. Until that administrative
step completes, GitHub remains on the current Cloudflare webhook ingress and
the board remains local/private. No stable Tailscale webhook success is claimed.
Verification after the fixes:
- Focused Discord and run-publication unit tests: **19/19**.
- Server `tsc --noEmit`: passed.
- Fresh full chat integration: **252/252**, zero skips, database
`chat_adapters_test_20260907_discord_member_02`; report
`.paperclip-runtime/chat-adapters-live/discord-member-integration-20260907-02.json`.
- The first fresh run was **251/252** because a Slack exact-redelivery test
sampled its transport count before prior durable denial effects finished.
The test now waits for those effects and additionally proves redelivery
creates no new effect row; no production queue behavior was relaxed.
- Live browser checks covered real provider credential verification and the
GitHub guest-refusal round trip, not a successful agent conversation. The
broader deterministic browser suite was not rerun for these server changes.
### Stable ingress and linked-account qualification — 2026-09-07, continued
The operator completed Tailscale Funnel enablement and the GitHub identity
confirmation. These observations supersede the pending gates above:
- The stable webhook origin is
`https://dottas-macbook-pro.tail29c1aa.ts.net:10000`. Funnel forwards only to
the webhook-only proxy on loopback port 3104. Existing tailnet-only routes on
443 and 8443 were not made public. Public board health/company requests
return **404**, and an unsigned recognized GitHub ping returns **401**.
- GitHub's App settings and Paperclip now use that stable origin with the
existing endpoint path and signing secret. A signed, real issue comment
reached Paperclip through Tailscale. The obsolete temporary Cloudflare
tunnel was stopped after this positive ingress evidence.
- The private confirmation flow linked `cryppadotta` to the local Board account.
A new conversation, rather than the earlier guest-admitted CHA-1, was used
for the linked-account test.
- [Enabled-repository issue 2](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5571135634)
created exactly one conversation and task **CHA-2** and received a receipt
reaction. **This was not a successful agent-answer test:** the pinned Codex
ACP runtime converted an unsupported-model provider error into assistant
text and reported the run as completed. Paperclip then published that raw
diagnostic. This is a release-blocking error-classification/publication
defect, not acceptable chat output.
- The installed `codex-acp` 1.6.2 process runs its bundled Codex 0.148.0, not the
separately installed CLI. The test agent had inherited the operator's Astra
model. Only the isolated Maya fixture was pinned to Paperclip's existing
`gpt-5.6-sol` default for further qualification; no global model, CLI,
credential, or unrelated agent configuration was changed. Successful live
runtime execution still needs proof after the typed-failure repair.
- [Disabled-repository issue 1](https://github.com/cryppadotta/paperclip-chat-e2e-disabled/issues/1#issuecomment-5571234021)
received an explicit bot mention. GitHub delivery
`9b1d68a0-aabe-11f1-80a1-0922ed513425` returned **200**, body **ignored**.
The repository remains disabled in Paperclip, with no conversation or task
created. This is provider-backed negative-reach evidence, not merely an
absence of a visible reply.
- GitHub's real redelivery control resent the existing CHA-2 root delivery
`8c9c7240-aabd-11f1-86a6-ed31986fb576`. Tailscale ingress returned **202** at
`2026-09-07T13:27:39Z`. Before/after counts were unchanged: two endpoint
conversations, three CHA-2 runs, two CHA-2 publications, and six CHA-2
comments. Redelivery did not create another task, wakeup, or publication.
- Discord's signed-in browser session now reaches Clawd. A real root mention
created its native thread and **CHA-3**, with a receipt reaction and the
expected safe guest-isolation refusal. Eigenjoy was subsequently linked to
the local Board account through the private confirmation flow. A fresh
linked Discord thread is still required; CHA-3 retains its original guest
trust classification.
No provider secret, private key, clipboard value, or one-time confirmation URL
is recorded here. Neither GitHub nor Discord is being declared fully qualified
from connection, receipt, or guest-refusal evidence alone.
### Real final replies and queue-quality findings — 2026-09-07, 13:45 UTC
The shared typed ACP terminal-error repair was committed and pushed as
`1325329e3`. Both supported acpx patches now negotiate typed session-failure
metadata and fail closed on terminal errors rather than treating their raw
provider diagnostics as an assistant answer. Warnings and ordinary quoted
error-like content are not classified by text matching. The broad focused ACP
regression slice passed **211/211**, with zero skipped cases.
The next live run exposed a second, independent defect: the model returned the
requested exact answer, but Paperclip selected an earlier bookkeeping comment
for publication. The working-tree fix gives the runner-selected final sole
ownership of the external response for chat-origin runs. Intermediate comments
remain internal, and a yielded or missing final cannot publish an internal note
as a fallback. Explicit Board **Send to channel** remains a separate action.
The isolated server restarted at `2026-09-07T13:44:55Z` with that fix, durable
Discord receipt removal, and independent reconciliation lanes. Real UI tests
then verified:
- GitHub's unmentioned follow-up stayed on CHA-2. Run
`b7190e01-0176-4af7-a471-c1e013c2a015` succeeded and
[reply 5571558895](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5571558895)
contained exactly `GH-LIVE-0907-ROUNDTRIP-OK`.
- Discord's linked root created CHA-4 and native thread `1546513811672932372`.
An unmentioned follow-up stayed in that task; run
`2443fa37-ea1e-436b-a1af-3ad6e58afc51` succeeded and
[reply 1546516692031504485](https://discord.com/channels/1457808928258658549/1546513811672932372/1546516692031504485)
contained exactly `DISCORD-LIVE-0907-ROUNDTRIP-OK`. Its receipt reaction
cleared after the terminal reply.
- Both setup wizards completed through their real **I've sent the test
message** controls; both endpoints are now `active` with setup complete.
These are successful core live replies, not a full production-quality pass.
The follow-on observation found that generic stranded-task recovery incorrectly
started an extra run after each completed turn. The tasks intentionally stay
`in_progress` while their external conversations wait for another user message;
that state was mistaken for unfinished productive work. A narrow recovery
repair and live no-extra-run retest are still pending at this checkpoint.
The focused server checks passed **70/70** and deterministic browser checks
passed **5/5** on the final-selection/scheduler/receipt changes. Shared, server,
UI, adapter-utils, and codex-local TypeScript checks passed. The fresh full
chat integration run is being repeated after its synthetic final-response
fixtures were updated to the new explicit runner-selection contract. These
figures do not claim the repository-wide suite or remaining live matrix passed.
### Ordered bursts and recovery regression — 2026-09-07, 13:58 UTC
After restarting the isolated server at `13:54:51Z` with the chat durable-wait
guard, three messages were sent rapidly through each real provider UI. All six
inbound comments persisted in provider order on the existing CHA-2 and CHA-4
tasks. Each provider started one run for the first message and coalesced the
two following messages into one durable deferred wake and one subsequent run.
GitHub returned `DELTA`, then exactly `DELTA EPSILON`; Discord returned its
first-word acknowledgement, then exactly `ALPHA BETA`. The separate threads
did not mix their code words. Discord cleared all three working receipts.
The four causal runs succeeded. No unsolicited recovery run appeared in the
post-burst observation. Both tasks were marked done by the agent, however, so
that absence alone does not prove the narrower in-progress chat-wait guard.
A live keep-open retest remains necessary. The first runs took about 7883
seconds, and the queued runs took about 15 seconds for GitHub and 50 seconds
for Discord. Ordering and correctness passed; those observed delays are not
an instantaneous-chat performance claim.
The final fresh chat integration suite passed **255/255**, with zero skips,
on `chat_adapters_test_20260907_live_hardening_05`. The final full process
recovery suite passed **133/133**, with zero skips, including active/waiting
chat idle behavior, completed-conversation recovery, ordinary non-chat
recovery, and pending in-review participant recovery. The production guard
requires an in-progress task, a successful external-chat run, and its
company/issue-bound active or waiting conversation; explicit queued work
is checked first and remains runnable.
### Clean keep-open proof and split webhook topology — 2026-09-07, 13:59 UTC
The clean-source keep-open retest on revision `5bd9c0d55` supersedes the
remaining recovery caveat above:
- GitHub run `c3335bdf-6a2e-49a5-82eb-8d31df92e4d0` ran from
`13:59:33.398Z` to `13:59:39.464Z` on the existing CHA-2 conversation.
[Bot comment 5571729974](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5571729974)
contained exactly `GITHUB-IDLE-WAIT-OK`.
- Discord run `f8c9dbe2-7e94-469d-8345-717eb7dad1bf` ran from
`13:59:31.087Z` to `13:59:38.234Z` in native thread
`1546513811672932372`.
[Reply 1546520298793468036](https://discord.com/channels/1457808928258658549/1546513811672932372/1546520298793468036)
contained exactly `DISCORD-IDLE-WAIT-OK`.
Both tasks intentionally remained `in_progress` with active conversations for
more than eight minutes after those terminal replies. Neither received an
additional run. This is the missing live proof that the recovery guard leaves
healthy external-chat tasks idle until new inbound or explicitly queued work
arrives.
The public callback topology is now split without exposing the Board:
- HTTPS `:8443` is the canonical Telegram webhook origin and forwards only to
the loopback webhook proxy on port 3104.
- HTTPS `:10000` remains available for the existing Slack and GitHub callback
URLs and forwards through the same webhook-only proxy.
- HTTPS `:443` remains tailnet-only for the private Board. Public health,
company API, and other Board routes are not forwarded by either webhook
listener.
The latest setup-edge full chat integration suite passed **258/258**, zero
skips. The combined process-recovery/status-payload suite passed **135/135**,
zero skips. The deterministic browser suite passed **5/5** on clean revision
`5bd9c0d55`, before the latest setup-edge/UI changes; it is still pending on
the current working tree, so this checkpoint does not claim a current browser
pass.

View File

@ -0,0 +1,199 @@
# Images and files — live qualification, September 7, 2026
This is an incremental evidence log, not a blanket production-readiness claim.
Live provider actions use the signed-in in-app browser. The isolated Paperclip
instance is on loopback port 3103; only verified webhooks are publicly routed.
## Reproduced user failure
Discord CHA-4 run `9b90ddaa-6d82-4685-84b1-9483c30de346` generated and uploaded a
2,111,878-byte PNG. Artifact `43104a30-4ae5-4078-a880-68c9f9720318` pointed to
attachment `7abdf671-1eb2-402a-8417-274b048c39ed`, but the attachment had no comment
binding. The run's final publication contained no attachment IDs. The bot's claim
that the image was shown was false. Both the npm CLI attempt and a workspace-local
CLI fallback failed. The image itself was intact in Paperclip storage.
The audit also found a second path: a successfully bound, during-run attachment
could remain internal when a different final presentation comment was published.
An explicit same-run attachment handoff and a bundled API-based artifact helper
now pass the normal live workflow below. Independent review additionally hardened
immutable upload provenance, the per-turn file cap, and helper retries.
## Native transport checks
One known, non-sensitive orange-cat PNG and a 128-byte text fixture were uploaded
through Paperclip's Board attachment API and explicitly sent to each existing QA
conversation. This isolates native transport from agent-generation/handoff logic;
it does **not** prove the agent handoff fix.
| Provider | Observed outcome |
| --- | --- |
| Discord | Cat rendered in the native media viewer; text file rendered with its exact contents. Image message `1546531868575535114`; file message `1546531871523995698`, in thread `1546513811672932372`. |
| Slack | Bot image loaded at 1024×1024 and text file preview contained the exact fixture contents in the existing CHA-6 thread. |
| Telegram | Bot image loaded at 800×800; document message `417200359:11` downloaded through the actual UI. The downloaded 128-byte file matched the source SHA-256 exactly. |
| GitHub | App comment transport is link-only for attachments; direct upload is not qualified. Live Board file send published a caption and one explicit private-task notice per selected file, starting with comment `5572594232`. No file bytes or loopback URLs were exposed. The misleading generic `Shared filename` preface was replaced and the final live retake verified the neutral wording below. |
| Teams | No live media claim: Microsoft 365 tenant/admin setup remains unavailable. |
Text fixture SHA-256:
`fd40030afb62b83181a2a46dde8220e8defecfa0b4328e380c30b1899ccdce24`.
Telegram's browser download event timed out, but the host download appeared in
Downloads at 09:46:27 local time and its size/hash verified successfully. This was
a browser event-observation limitation, not a failed file delivery.
## Inbound inspection checks
Files were uploaded through each provider's real message composer. The bot was
asked to inspect actual bytes, not infer content from filenames.
- Slack: run `10aa0f41-0cc3-4997-95b4-f50eb1e033e8` succeeded, identifying the orange
tabby/green eyes and reading `cobalt otter 47.`. Both stored attachments were
bound to inbound comment `cfb14fe0-f463-4952-9cf9-2acdc32997b2`. Final bot message
`1788792053.513999` is in root thread `1788789960.341109` in `C0BUT55N9RV`.
The run took about 135 seconds; this remains a usability concern.
- Telegram photo: run `03f06e17-a0e5-43e5-a894-0cea68566aa3` identified the cat,
eyes/nose, sofa, plant and window from the inbound JPEG. Final message
`417200359:6`; about 132 seconds.
- Telegram document: a follow-up sent while the image run was active queued and
then ran as `41215211-debf-44cc-9b93-a220fd0931de`. It returned the exact phrase
in `417200359:8`; about 81 seconds after execution began. The two messages stayed
on CHA-8 and produced separate, correctly ordered responses.
- GitHub private issue upload: native UI produced an HTML image plus a Markdown
text-file link. Human comment `5572301393`, bot `5572302077`, run
`0c252a02-51cc-4aeb-b829-73865415070e`. The bot did not claim to inspect unavailable
bytes, but described the active chat connection as unavailable and requested
a separate tool connection. This is **not** a successful inbound media check;
chat transport must explain its file/link limitations clearly.
- Discord: run `888e586d-b62e-454c-bb77-d4d0c14ea245` inspected both inbound files,
identified the cat/green eyes/sofa/plant, and read the exact phrase. Final bot
message `1546532360630177873`, about 146 seconds after execution began.
## Normal agent handoff retake
After restarting the local server with the handoff fix at 14:56:49 UTC, each
existing provider conversation received an ordinary request to return the cat
and create a text file with a provider-specific exact marker. The requests did
not tell Maya which tool or helper command to use. All three stayed on their
existing task, succeeded, and published both selected attachments.
| Provider | Run and real-provider proof |
| --- | --- |
| Discord | Run `448779d2-73a3-4f39-9f75-0c9fdac528d0`, 14:57:1015:02:43 UTC. Native image message `1546536207448547401` loaded; native file `1546536210195808318` previewed exactly `DISCORD-FILE-HANDOFF-0907-OK`. |
| Slack | Run `d4b00e25-a2b6-49bd-9442-384419c88776`, 14:57:1715:02:00 UTC. Both native files appeared in CHA-6's original thread; the image loaded at 1024×1024 and the file preview showed `SLACK-FILE-HANDOFF-0907-OK`. |
| Telegram | Run `949a2b1a-5f6c-4680-971c-cceb244be8a5`, 14:57:2315:02:24 UTC. Image `417200359:14` loaded at 800×800. Document `417200359:15` downloaded through Telegram's real UI; its 29 bytes were exactly `TELEGRAM-FILE-HANDOFF-0907-OK`, without a trailing newline. |
The Telegram download SHA-256 was
`a0692bcddade1e6e9e1a15ee975c2c2d501be8bdc34c5e1cbe84b3de4e7b2f7f`.
Paperclip's outbox independently showed all six attachment publications as
`published`, one image and one file per provider, with no duplicate file sends.
The final prose said the files were **prepared**, not falsely provider-confirmed.
This repairs the reported missing-image failure, but the 283333 second agent
turns are too slow for a polished simple file reply. The Discord run made 28
completed/failed tool calls, including avoidable connection discovery. The task
prompt now explicitly directs external file replies to the installed artifact
helper and away from provider-tool discovery or fetching a CLI. The final retake
below measures the improvement; native delivery success does not prove the
interaction is fast enough.
The Paperclip task transcript also passed a live UI check: inbound images and
files appeared even when the comment had no Markdown reference, the image opened
in the gallery at full size, and the text-file link opened its exact content.
## Implemented hardening
- Render provider-bound comment images/files in the task transcript, even when
its caption contains no Markdown attachment reference.
- Include bounded, task/comment-scoped attachment descriptors in wake context so
agents can discover and download the files without searching the whole task.
- Carry only explicitly selected same-agent/same-run attachments into final chat
delivery. Never infer authorization from an unbound artifact alone.
- Explain GitHub's link-only behavior before an explicit Board file send and in
the provider fallback. Do not expose loopback URLs or publish private files to
an unrelated public upload service.
- Record immutable originating-run attribution on upload; never derive authority
from editable work-product records or backfill ambiguous legacy files.
- Serialize both comment binding and direct-to-comment uploads. Reject a
twenty-first chat file with an actionable error rather than silently dropping
one; preserve ordinary non-chat multi-comment uploads.
- Recover matching uploads using immutable origin and exact content hash. Local
concurrent helpers serialize; ambiguous network/408/5xx/malformed-success
outcomes fail closed until the durable attachment is found or an operator
explicitly accepts duplicate risk. This is not cross-host exactly-once upload.
- Post a single generation-fenced notice after a definite supported-provider
file rejection, without replaying an ambiguously delivered file.
## Automated checkpoint before upstream reconciliation
- Fresh database chat integration: **262/262**, no skips.
- Focused server provider/projection/attachment tests: **313/313**.
- Executable artifact helper retry/concurrency tests: **18/18**.
- Focused UI tests: **120/120**; deterministic provider browser flows: **5/5**.
- Recovery/status/context checkpoint: **153/153**, using an explicit fresh
PostgreSQL database instead of silently skipping unsupported embedded tests.
- Attachment wake-context scope/quarantine database checks: **6/6**, no skips.
- Migration snapshot drift: **1/1**. Workspace typecheck, workspace build, and
UI token gates passed. These build checks precede the final provenance edits;
final targeted compile is repeated before handoff.
## Final merged-build retake
Merged `origin/master` at `f6a211479`, retained the media hardening, and corrected
the connection wizard's tool-method selector after reconciliation. Restarted the
live server with migration 0249 applied. Three ordinary requests were sent from
the signed-in provider composers at 15:30:2527 UTC, without helper instructions.
| Provider | Observed result on the final media implementation |
| --- | --- |
| Discord | Run `15f7af18-d052-44d3-9698-127433b9e941` succeeded in 163 seconds. Native image `1546543862883946597` visibly rendered the cat; file `1546543864142102529` previewed `DISCORD-MEDIA-FINAL-0907-OK`. |
| Slack | Run `d5e7b996-1fc0-41e4-92fd-c5522fd23fbb` succeeded in 183 seconds. Native file message `1788795212.198169` previewed `SLACK-MEDIA-FINAL-0907-OK`; image message `1788795215.443269` visibly rendered the cat in the same thread. |
| Telegram | Run `783a9af6-eefd-4d24-a39b-ce8eac97bdcf` succeeded in 151 seconds. Photo `417200359:18` loaded at 800 pixels wide; document `417200359:19` downloaded through the real UI. |
| GitHub | Fresh Board file send `4a82fa40-5fc0-42f7-99ac-ddc97c5b2ff8` produced comment `5572840135`: the file is saved on the private Paperclip task and this GitHub App connection cannot upload file bytes into comments. No misleading “Shared” preface or public file URL. |
All six native attachment publications were `published` with one attempt each;
each upload carried the correct immutable originating run. The refreshed
Paperclip task transcript showed the newly bound images/files, and the native
provider threads showed one copy of each selected file. GitHub's first fallback
retake attempted to reuse already comment-bound attachment IDs and correctly
received 409; a fresh QA upload was used instead, not a forced rebinding.
The downloaded Telegram file was 29 bytes with SHA-256
`464d31c3110370919f443cfb3576b836812f8590dd3bbf8572352d2cf4ed3136`, exactly matching
Paperclip's stored asset. It contained the requested marker **plus a trailing
newline**. The transport preserved the bytes correctly, but this is not an
exact-byte content-generation success. Discord's text also included a newline;
Slack's 25-byte marker had none. Do not silently rewrite generated file bytes in
the transport to hide a model-content mismatch.
Functional delivery is repaired. The 151183 second turns improved substantially
from 283333 seconds, but remain too slow for a polished simple file reply.
GitHub private inbound attachment bytes remain unqualified and the current
outbound adapter remains link-only. Teams remains live-unqualified without the
Microsoft 365 tenant/admin setup; no universal “files work everywhere” claim.
Post-merge verification:
- Connection/GitHub/tool-access/migration regression slice: **387/387**, no skips.
- Workspace typecheck and workspace build: passed on the final merged sources.
- Deterministic provider browser flows: **5/5** on another fresh database.
- UI token gates and `git diff --check`: passed.
- Native-session and adapter-registry tests: **158/158**.
Runtime reproducibility caveat: these live Maya retakes used the retained ACP
installation resolving Codex 0.148.0 with the Sol fixture model. The merged
manifest now requests 0.153.4, also installed as the global CLI. The local
dependency tree was not re-resolved during handoff, because doing so without the
CI-owned lockfile would also refresh ranged transitive dependencies and change
the just-qualified environment. Neither the lockfile nor Maya's model/engine
was modified. The next normal dependency refresh must requalify the current
runtime; these results do not establish that 0.153.4 ACP combination.
The broad workspace run was stopped after fixture/mock failures and does not
have a passing final summary. It also overlapped upstream reconciliation, so it
is not a valid final-tree checkpoint. Only the explicit focused runs above are
claimed green.
GitHub's [issue-comment REST API](https://docs.github.com/en/rest/issues/comments#create-an-issue-comment)
accepts a comment body, unlike the browser's separate
[file attachment workflow](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files).
The shipped adapter's link-only behavior is a scoped product limitation; it is
not evidence that every possible GitHub integration can never transfer files.

View File

@ -0,0 +1,642 @@
# Native chat Board files and webhook recovery — 2026-09-07
## Environment and scope
Isolated Board `http://127.0.0.1:3103`, company Chat Adapter E2E, snapshot 10,
loaded server `2026.831.0+396.git.dde176bbc`. The branch HEAD was `66a68fee5`
(documentation-only after the running implementation). Maya E2E remained
`paperclip_runner``codex``gpt-5.6-luna`.
These are real signed-in in-app browser checks against the configured Slack,
GitHub, Discord, and Telegram sandboxes. They deliberately start no model turns:
the Codex account limit still prevents additional native model qualification.
They do not qualify Teams, which still needs an eligible tenant/admin setup.
## Bounded webhook outage
Paused only the owned webhook proxy process with `SIGSTOP` at
**19:23:33.168 UTC**. A separate watchdog automatically sent `SIGCONT` after
45 seconds, at **19:24:18.173**. The Board server and Discord Gateway remained
running. The proxy was verified running afterward with the same PID and command.
Added then removed our thumbs-up on the existing admitted Slack message and the
completed generation-5 Telegram reply. No provider message, task, bot reaction,
credential, callback URL, or endpoint reach setting was changed.
| Provider | Event | Browser action UTC | Received → processed UTC | Delivery ID |
| -------- | ------ | ------------------ | --------------------------- | -------------------------------------- |
| Slack | Add | 19:23:39.001 | 19:24:18.420 → 19:24:18.426 | `4453aff3-e004-442b-a340-af44b4e0037f` |
| Slack | Remove | 19:23:42.874 | 19:24:18.419 → 19:24:18.424 | `dd006230-f1c1-4195-86b8-3a5a0f364ed9` |
| Telegram | Add | 19:23:39.333 | 19:24:18.191 → 19:24:18.197 | `e26e155f-e14f-4fa5-85b1-a291a997df91` |
| Telegram | Remove | 19:23:48.281 | 19:24:18.341 → 19:24:18.343 | `2cdc0a84-d8ef-4f85-99df-7d184192c07a` |
During the pause, at **19:23:57.284**, the proxy was stopped and there were zero
new delivery rows. All four events were subsequently processed with null error.
At the later check after **19:29 UTC**, there were still exactly four rows, not
late duplicate receipts. Counts before any Board sends remained **86 Maya runs,
17 tasks, 216 comments, 200 publications**.
Telegram's already-mounted Activity automatically showed the recovered pair,
and the rows were visually inspected. Slack retried **remove before add**.
Current Activity is a receipt/processing history, not provider occurrence
chronology; it does not persist an occurrence timestamp or reconstruct reaction
state. These events do not wake an agent or change task authority. This proves
loss-free recovery for this bounded reaction outage, not ordered Slack replay,
an exhaustive retry window, or a live Discord Gateway interruption.
This check did not reconfigure Telegram's webhook URL. The separate historical
URL-changing reconnect/backlog proof is recorded in the
[Telegram result](./2026-09-05-telegram-live-qualification-result.md).
## Explicit Board file sends
Prepared fixtures through the real Board attachment API, not direct database
inserts. Each existing linked task received three unbound files: a selected
128-byte text document, a selected 2,111,878-byte PNG of the previously used cat,
and an unchecked `internal-only.txt`. File prefixes were
`board-qa-1930-{provider}-`.
- Document SHA-256: `fd40030afb62b83181a2a46dde8220e8defecfa0b4328e380c30b1899ccdce24`.
- PNG SHA-256: `7693966f6c2b4aaebf9e46359f715fdaede021346bcd926078bb331b1dddc3c1`.
Started from Telegram connector Activity → Conversations → Open task. Used the
actual **Send to channel** composer, selected only the named document and PNG,
and explicitly identified the message as a transport test requiring no reply.
Continued to the existing Slack, Discord, and GitHub tasks and repeated the same
UI action. The unrelated pre-existing Discord attachment stayed unchecked.
| Provider | Board click UTC | All three publications confirmed UTC | Canonical Board comment |
| -------- | --------------- | ------------------------------------ | -------------------------------------- |
| Telegram | 19:28:53.440 | 19:28:56.199 | `2ee161b6-3fda-4ff7-b23b-c8f19c2fd087` |
| Slack | 19:29:18.770 | 19:29:20.468 | `efc7dd3c-9c7a-46c7-b026-aadb7e3402c2` |
| Discord | 19:29:38.549 | 19:29:40.179 | `e32c0140-c16d-43f1-838d-c657f2891bd9` |
| GitHub | 19:30:44.104 | 19:30:46.196 | `45cacf74-dc9a-4d0a-abd9-dfef3ce3d73b` |
Slack and Discord visibly rendered the document's `cobalt otter 47` verification
phrase and the cat image. Slack's full image viewer was opened and inspected.
Telegram visibly rendered a 128-byte document card and the cat photo. This
batch does not claim a downloaded-byte checksum of the provider copies.
GitHub visibly posted the Board text and two honest private-task file notices;
it did not claim to upload bytes or expose a private Board URL. The selected
files were available on the Paperclip task after reopening it. GitHub's App
transport limitation remains explicit, not a passed native image-upload claim.
Each send produced exactly one canonical comment and three ordered published
rows with provider message IDs and null errors. All eight selected attachments
were bound to their respective comment. All four unchecked fixture files stayed
unbound and had no publication. Counts became **86 Maya runs, 17 tasks,
220 comments, 212 publications**. No additional model run or task was created.
## Experience findings still requiring a fix/retest
The provider-side outcomes above passed, but the Board experience needs work:
1. Slack's send returned **Publishing to channel** with a retained disabled
draft even though all three rows subsequently published. The component keeps
that returned state without an authoritative refresh. This visit navigated
away before measuring an indefinite stale state; a deterministic regression
must establish and fix that terminal-refresh gap without replaying the send.
2. On GitHub's canonical `CHA-2` task route, the newly sent comment/files did not
appear in the mounted timeline after completion. Reopening the task showed
them. The banner invalidates UUID-keyed queries while the page can use an
issue-identifier key. The same useful outcome must become visible without a
reload.
An independent code audit also found outbound file hydration lacks a bounded
storage read and persisted SHA-256 verification. That is failure-injection work,
not a corruption observed in these successful live sends. Fixes and supporting
tests are being handled separately; none is qualified by the preceding baseline.
## Follow-up implementation and deterministic verification
The outbound reader now checks the persisted SHA-256 and exact byte length,
bounds storage acquisition and streaming to ten seconds each, and destroys a
stream returned after timeout. Task/comment scope and metadata validation run
before storage access. Invalid metadata fails definitively; storage/query/read
failures remain safe pre-provider retries under the existing five-attempt limit.
An accepted provider send with an uncertain durable result still becomes
`delivery_unknown`, never an automatic retry.
The Board composer now uses a scoped read-only batch-status endpoint. It waits
for every text/file part, observes explicit Activity resolution, and refreshes
both UUID and canonical-identifier task caches. Its exact submitted payload,
selected files, and idempotency key are stored before POST in session-scoped
browser storage. Reload resumes a known anchor through GET only; a lost response
restores a locked draft with an explicit same-key **Retry safely** action.
Storage failure before submission prevents an untracked send. State and late
responses are isolated by company, task, endpoint, and conversation. This is
reload/navigation continuity within that browser session, not a cross-device
draft synchronization claim.
Verification before restarting the live server:
- Fresh PostgreSQL integration: **269/269**, database
`chat_adapters_test_20260907_latency_17` (78.12 seconds).
- Focused UI/API/OpenAPI/draft tests: **43/43**; separate hydration/API/OpenAPI
subset: **17/17**, including four bounded-read/integrity unit cases.
- Five-provider browser file plus Board regressions: **9/9**; clean final Board
subset after scope hardening: **4/4** (39.9 seconds).
- Shared/server/UI typechecks, UI token gates, and diff checks passed.
- The lockfile was unchanged; no broad workspace-test pass is claimed.
The previous DB14 run passed 268 cases before the final pretransport guard
expansion. DB15 exposed metadata validation being masked by missing storage;
the guard ordering was corrected, not the expected security result weakened.
That run also exposed leaked retry work in a projection-only test fixture. The
fixture now retires its exact staged publication and shuts down its service;
new hydration tests shut down in `finally`. DB16 passed the new cases but found
a timing assumption in a GitHub lease test: a nonblocking HTTP response can
precede the worker claim. The test now waits for the same required `processing`
state while the lease is held. DB17 is the clean combined result above.
An early full browser run overlapped development hot reload and missed one
success toast; the final clean runs supersede it. The initial red browser test
also established that the old component made zero status GETs for eight seconds
and kept the completed send disabled.
The updated-backend live retest below is separate from these deterministic
results. A further code audit found synthetic Slack file-share message IDs;
reaction matching on uploaded Slack files is not yet qualified.
## Updated-backend live retest
Restarted only the isolated Board server as snapshot 11. Health reported loaded
`2026.831.0+399.git.43b63da40`, process start **19:52:33.594 UTC**, startup recovery
ready, and the Discord Gateway connected. Maya's safe configuration fields were
rechecked: `paperclip_runner`, provider `codex`, model `gpt-5.6-luna`. The stored
reasoning-effort setting is `low`, but the current native input contract does
not propagate that legacy field, as documented in the native-runner report.
The following checks do not invoke the model.
### Slack: paused queue, reload, and automatic completion
Used the connector Activity **Pause** control, then the canonical `CHA-6` task's
**Send to channel** UI. Selected only `board-queue-retest-note.txt` and
`board-queue-retest-cat.png`; the previous internal-only fixture stayed unchecked.
Clicked Send at **19:53:07.191 UTC** with marker `BOARD-QUEUE-RELOAD-1954`.
The mounted timeline immediately showed exactly one Board comment and both
attachments. The composer truthfully showed **Queued for channel**, **0 of 3
parts published**, and a locked draft. Reloading preserved that exact draft and
status. A database check while still paused confirmed one comment and three
pending rows, not a duplicate submission:
- Comment: `9e461b1e-ccc9-478b-9799-5fce4c6d96b1`.
- Publications: `85a6121d-4554-4c52-89ad-7725b0603329`,
`acb97e67-a44a-439b-8828-ad2ab4c95114`, and
`ecc5e90f-d5cb-4fcd-b8be-3dda7ffe84f9`.
Clicked **Resume** at **19:53:35.884**. Text published at **19:53:38.067**, document
at **19:53:38.560**, and image at **19:53:39.154**, all with null errors. By the
next UI observation at **19:53:42.451**, the same mounted task had automatically
closed the draft and re-enabled Send. Slack's actual thread visibly contained
the marker text, the document preview with `cobalt otter 47`, and the cat image.
There was still one canonical comment. Slack was left active.
One remaining experience defect was observed and assigned for correction: once
the selected attachments bind to the new comment, they disappear from the
pending selection list, leaving only the unchecked internal-only file visible.
Although the timeline and three-part status are correct, the composer should
continue showing the exact locked selected filenames through reload.
### Discord, Telegram, and GitHub on the same backend
Uploaded two new unbound fixtures per provider using the Board attachment API,
then selected them through each canonical task's actual Send composer. Markers
were `BOARD-NEW-BACKEND-{PROVIDER}`. Unrelated and internal-only files stayed
unchecked. No agent reply was requested.
| Provider | Board click UTC | All three parts published UTC | Canonical comment |
| -------- | --------------- | ----------------------------- | -------------------------------------- |
| Discord | 19:54:40.669 | 19:54:46.478 | `0cbe837f-b48c-42dc-b36f-f2bc7c901ec2` |
| Telegram | 19:55:11.630 | 19:55:14.991 | `57639d0a-4c3e-4b2b-80c8-e36c5311fdc6` |
| GitHub | 19:55:36.324 | 19:55:38.967 | `0b206e5a-4171-4fb8-b8af-773ad612f419` |
All nine rows were published with real provider message IDs and null errors.
Discord visibly rendered the text preview and cat; Telegram rendered its 128-byte
document card and cat photo. The composer closed automatically on each task.
GitHub posted the accurate private-file notices. Its already-mounted canonical
`CHA-2` timeline now showed the new comment and files without reopening; the cat
opened successfully in the private task's full image viewer.
Final counts were **86 Maya runs, 17 tasks, 224 comments, 224 publications**.
All four configured endpoints were active; all four previous internal-only
fixtures remained unbound. These checks establish successful transport and Board
recovery on the updated backend, not additional model qualification, a throughput
SLA, downloaded provider-byte checksums, or native GitHub file upload support.
### Retained filename receipt and resume latency finding
UI commit `9763e11fc` fixes the pending-file selection issue. Its filename
snapshots remain local to the session's existing scoped send record; they do
not change the publication payload or authorize resending bound files. Focused
tests passed **46/46**, mocked Board browser cases **4/4**, and UI typecheck,
token gates, and diff-check passed.
Retested live with the unchanged snapshot-11 backend and the refreshed Vite UI.
Paused Slack, then sent `BOARD-RECEIPT-CHECK` with `board-receipt-check-note.txt`
and `board-receipt-check-cat.png` at **19:58:15.799 UTC**. Before and after reload,
the composer showed **Files in this send** with exactly those two names checked
and disabled. The canonical timeline also showed the one new comment and both
attachments. After eventual completion, a new empty draft offered only the
unbound internal-only file, not the files already sent.
The resume at **19:58:22.029** exposed a separate scheduling defect: the paused
head had acquired a synthetic deadline of **19:58:45.898**. Text, document, and
image eventually published at **19:58:46.193**, **19:58:46.887**, and
**19:58:47.515**, under comment `0eae4850-b752-4f62-bd63-05ff6c27e427`. Slack
visibly received all three, but the approximately 25-second post-resume wait is
not acceptable transport latency. The scheduling correction and its live retest
are separate from the successful filename-persistence result.
## Subsequent scheduling and Slack identity hardening
The publication selector now excludes paused/attention endpoints before applying
its global page limit. A pause racing an already-selected row restores its
original deadline, not a synthetic 30-second delay. Resume therefore makes due
work eligible immediately without clearing genuine provider rate-limit or
storage-retry deadlines. DB18 reproduced both the old delay and starvation with
a one-row page. The revised fixture also resumes through the real configuration
service and verifies an unrelated provider backoff remains unchanged. DB19
exposed incomplete fixture inventory during provider revalidation; the test now
returns its actually available channel rather than bypassing the reach check.
The pinned Slack adapter now uses the uploaded file's real share timestamp for
its exact channel/thread. Sparse upload responses use a bounded, read-only
`files.info` lookup under the existing required `files:read` scope. Every returned
file must match its expected uploaded ID and have one unambiguous common share
timestamp. Missing, mismatched, timed-out, or ambiguous identities after upload
remain `delivery_unknown`, not synthetic success or a retry that uploads again.
An unpreparable local file fails definitively before transport. Adapter and
bounded-hydration units passed **49/49** after the final patch; server typecheck
passed. Applying the tracked patch to pristine 4.39.0 reproduced the installed
adapter bytes exactly. The lockfile remains unchanged as instructed; a fresh
frozen-lockfile install was not part of this check.
DB20 passed **268/269**, including the new resume and exact Slack file-ID tests.
Its failure was an existing slash-command test that raced provider-root
completion against channel-access revocation and assumed a task must result.
The recorded delivery was correctly filtered because the destination was
disabled. The test now explicitly controls transport and admission scheduling,
retains the duplicate-acknowledgement and lease assertions, commits revocation,
then drains the exact receipt and requires denial with no task or wake. No
production authorization check was relaxed to make that expectation pass.
The clean combined DB21 rerun passed **269/269**. The isolated deterministic
revocation test also passed on its own newly migrated database; server typecheck
passed after the final test changes. Live verification of the new Slack identity
and scheduling behavior follows separately.
The separate early-reaction race remains open: a reaction arriving before the
outbound message link commits currently has no exact lineage and is dropped.
Resolving real Slack file IDs fixes normal post-commit matching, not that race.
## Snapshot 12: fast resume passes; Slack share visibility exposes a failure
Loaded snapshot 12, `2026.831.0+402.git.dc1d17351`, at **20:09:56.742 UTC**;
health/recovery and Discord Gateway were ready. Paused Slack and sent
`SLACK-UPLOAD-ID-CHECK` at **20:10:56.530** with `slack-upload-id-note.txt` and
`slack-upload-id-cat.png`. Reload retained both checked, disabled filenames.
While paused, all three publications had zero attempts and null retry deadlines.
Resumed at **20:11:15.347**. The text published at **20:11:16.915**, a 1.568-second
resume-to-acknowledgement sample, without the prior synthetic delay. However,
the document then entered `delivery_unknown` at **20:11:17.645** because the
one-shot file metadata lookup could not resolve its share. The image remained
pending behind that uncertain result. The Board truthfully showed **Delivery
not confirmed**, **1 of 3 parts published**, and kept its exact draft.
Slack visibly contained the document and correct `cobalt otter 47` content.
Its native permalink timestamp was `1788811877.783349`, corresponding to
**20:11:17.783**: the actual share appeared about 138 ms after the adapter had
given up. This is failed file-identity qualification and evidence of eventual
share visibility, not a successful automatic file receipt. No upload retry was
performed. The follow-up uses bounded read-only polling for the same uploaded
file IDs, keeping the original upload and ambiguity safeguards unchanged.
- Canonical comment: `1181fcde-c098-4136-a2c9-e3dd13c6dd0c`.
- Text: `12b3bef4-390f-4e89-9dcf-cb930aa52f13`, real ID `1788811876.864209`.
- Held document: `ad9e2afb-85ab-4ed6-90bb-5168f760688a`.
- Pending image: `24d08c07-63de-45af-9f4b-8d1a8b00342b`.
The current operator **Mark delivered** action records an audited confirmation
but does not accept a recovered provider message ID or reconstruct its message
link. An operator-resolved document therefore must not be counted as a passed
automatic lineage/reaction test; a fresh normally acknowledged file is needed.
The follow-up adapter change polls sparse, matching `files.info` results under
one absolute five-second deadline, with paced 100/250/500/1000 ms waits. It never
uploads again and cannot start a lookup after a delayed token resolution has
exhausted that deadline. Missing/mismatched identities and lookup errors still
produce a safe uncertain-delivery result. Focused adapter and hydration tests
passed **51/51**; the first typecheck caught a generic mock typing error in the
new late-token test. The corrected test and server typecheck pass. Live qualification of
this polling change is recorded below rather than inferred from those tests.
## Snapshot 13: real file identities and file reactions pass
Loaded `2026.831.0+403.git.dddcf0d93` at **20:21:08.692 UTC**, with startup
recovery ready and Discord Gateway connected. Rechecked the existing document
in Slack, including its correct fixture text, then used Activity's **Mark
delivered** at **20:21:31.363**. It retained one upload attempt; no retry or
provider-ID backfill was performed. Its missing automatic lineage remains an
explicit limitation of manual resolution, not a successful identity test.
The previously pending image then published once, automatically, at
**20:21:36.719**, with real Slack ID `1788812496.261909` and a matching outbound
message link. Slack showed the cat image in the intended thread. The mounted
Board composer closed automatically after the batch completed. A new draft
offered the new unbound note and the internal-only fixture, not already sent
files.
Sent `SLACK-FILE-ID-RECHECK` from the Board at **20:21:55.713**, selecting only
`slack-upload-id-recheck-note.txt`. Text and document published in one attempt
each under canonical comment `f1118339-a6e2-40f5-b4e6-b67fe3883e1f`. The document
publication `e7517c83-15b2-4928-964b-422d1b64e8d1` completed at
**20:21:56.890**, with native ID `1788812516.721189` and a matching outbound link.
Slack visibly rendered the correct `cobalt otter 47` content. No manual
resolution or duplicate send was needed, and the Board draft cleared again.
Added and removed the operator's thumbs-up on that exact document, then on the
new image, through Slack's message controls. All four receipts processed once,
without error, and the Activity tab refreshed to show them:
| File | Event | Received UTC | Processed UTC | Exact native message ID |
| ----- | ------- | ------------ | ------------- | ----------------------- |
| Note | added | 20:22:38.257 | 20:22:38.262 | `1788812516.721189` |
| Note | removed | 20:22:56.917 | 20:22:56.921 | `1788812516.721189` |
| Image | added | 20:23:24.762 | 20:23:24.768 | `1788812496.261909` |
| Image | removed | 20:23:27.958 | 20:23:27.962 | `1788812496.261909` |
Both test reactions were removed; existing reactions were untouched. Maya's
run counts remained 78 succeeded / 8 failed, with no running or queued run.
An unrelated automatic productivity-review task, CHA-18, appeared during this
window; it has no chat conversation and must not be attributed to these
reactions. All four configured endpoints remained active.
Functional result: fresh Slack document/image delivery, exact outbound lineage,
post-commit file reactions, and automatic Board draft completion passed live.
The one-shot failure did require operator recovery; the corrected fresh-send
journey did not. This does not qualify the still-open reaction-before-link race,
native model generation under exhausted quota, or Microsoft Teams.
### Manual confirmation is not a recovered provider receipt
Read-only review confirmed that `mark_delivered` intentionally records the
operator's confirmation without inventing an external ID. Exact reactions or
later message replacement cannot use a link that does not exist. The current
adapter discards its known uploaded file IDs when bounded share lookup expires,
so old uncertain/manual-confirmed rows cannot safely be matched later by
filename, text, or time-window searches.
A future recovery path would need a durable internal partial receipt from the
original attempt: the exact server-observed file IDs, publication/attempt, bot
identity, and intended channel/thread. It could then repeat only scoped,
read-only metadata lookups under current authorization, require the same unique
share match, and transactionally bind the identity without another upload or
repeating manual-completion side effects. That path is not implemented or
claimed in this qualification.
## Early-reaction recovery hardening
The previously open reaction-before-link race now has a durable, bounded path.
If the exact message link is not yet visible, only a currently authorized
destination with an unambiguous in-flight publication can stage a minimal
reaction receipt. It has no conversation/task association until the exact
outbound message link exists. Recovery rechecks the endpoint/runtime fence,
destination reach, and current principal authorization; it never creates a task,
comment, run, or wake. Unknown unrelated messages are not admitted just because
they share a channel.
Pending reactions stay outside both ordinary inbound FIFO selectors. Their
metadata-only recovery runs alongside ordinary deliveries, with paced retries
bounded by 20 attempts and two minutes. Exact provider-event deduplication is
preserved across the original callback, retry, and server restart. A completed
DM generation can own its late reaction; a newer generation is never guessed.
Independent review found and corrected three subtle interleavings: publication
commit between the unlocked preflight reads; conversation FK key-share locks
deadlocking with endpoint-first reaction admission; and a pre-lock timestamp
allowing replay after expiry. Conversation locks now use `NO KEY UPDATE`, and
expiry is evaluated after acquiring the delivery lock. Recovery promises are
observed immediately and joined even if ordinary delivery processing throws,
before the original error is rethrown.
Seven focused real-PostgreSQL cases passed on fresh `reaction_focus_01` (5/5)
and `reaction_focus_02` (2/2): preflight recheck, durable duplicate/restart replay
without task work, publication-link lock overlap, late-duplicate expiry,
post-lock clock expiry, revoked destination, and completed older-DM ownership.
Server, shared, and UI typechecks passed. Cross-endpoint liveness under a held
reaction lock was code-reviewed, not a separately executed eighth fixture.
The full combined suite then passed **276/276** on the fresh migrated
`chat_adapters_test_20260907_reaction_full_01` database, in 72.10 seconds
(64.52 seconds of tests). This run included the final frozen service/test files
and all seven additions. Simulated provider failures in its log are intentional
negative fixtures, not live-provider failures.
### Recovery failure-path regression follow-up
Two additional real-PostgreSQL fixtures now exercise ordinary inbound-drain
failure while both action and reaction recovery are in flight. Each injects an
error only at the fixture endpoint's inbound lease acquisition. One releases
action recovery first; the other releases reaction recovery first. Both require
the sweep to remain pending until the second recovery finishes, then reject
with the exact original error. Durable reaction/action state completes once,
without extra comments, tasks, runs, wakes, or publication sends; the ordinary
delivery remains unprocessed and recovery leases are released.
The focused run passed **2/2** on fresh
`chat_adapters_test_20260907_reaction_join_01`; server TypeScript passed.
The isolated mocked browser suite also passed **9/9** in 2.7 minutes, covering
all five setup/management journeys and four Board batch-delivery/reload cases.
Those browser cases use a throwaway instance on port 3199 and mocked providers,
not the signed-in live provider sessions or the live instance on port 3103.
The combined suite then passed **278/278** on fresh migrated database
`chat_adapters_test_20260907_reaction_full_02`, in 79.68 seconds (71.62 seconds
of tests). This includes both recovery-release orders and the prior seven
reaction-link regressions. The current change is test-only; it does not add a
new live-provider qualification or change the running server's production code.
## Snapshot 14: deployed; post-restart browser smoke remains unverified
Loaded `2026.831.0+407.git.e6f52b4cc` at **20:44:52.362 UTC**. Health and
startup recovery are ready; Discord Gateway connected. All four configured
endpoints remain active. A read-only recheck confirms Maya still uses
`paperclip_runner` / `codex` / `gpt-5.6-luna`; the four earlier successful text
run rows retain `native` / `codex_app_server`. No model defaults were changed.
The attempted live post-restart reaction smoke did not complete. Browser click
and scroll calls returned without a visible effect in Slack and Paperclip,
including a newly opened Board catalog tab. One browser-automation session reset
and the documented alternate interaction API did not restore input. Navigation,
rendered snapshots, and screenshots remained available. No new Slack reaction
receipt arrived after this restart, and Maya's run counts remained 78 succeeded
and 8 failed, with no queued or running run. No duplicate message or credential
rotation was attempted as a workaround.
The new early-reaction path therefore has the database/integration coverage
above, but no passed post-deployment live reaction smoke. Snapshot 13's actual
document/image and reaction results remain valid evidence for that version;
they are not relabeled as snapshot 14 results. Browser-input recovery is a
testing-tool limitation, not an established Slack or Paperclip product defect.
Model-driven follow-ups still require restored Codex capacity; Teams still
requires the eligible tenant/admin setup. The isolated server is left running,
with the public webhook-only proxy and private Board boundary unchanged.
## Frozen-install release gate
The preserved lockfile is now a confirmed release blocker, not merely an
unexecuted check. On September 7, the non-regenerating diagnostic
`pnpm install --frozen-lockfile --lockfile-only --ignore-scripts --offline`
exited with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`: the current overrides do not
match the lockfile. It stopped before validating dependency and patch entries;
source inspection also shows that the five pinned chat-adapter dependencies
and their patches are absent from that lockfile. The diagnostic left the
lockfile and working tree unchanged and did not replace the live server's
installed modules.
The installed, patched dependency tree used for the recorded tests is therefore
not proof of a reproducible frozen installation from this branch. The existing
instruction not to edit or commit `pnpm-lock.yaml` remains in force. No patch,
override, or dependency was removed to make the check appear green. Release
qualification needs a reconciled lockfile and a clean frozen-install retest
after that constraint is resolved; the active local server is unaffected.
## Upstream reconciliation remains open
A fresh fetch on September 7 found `origin/master` at `d8b958053`, four commits
ahead of this branch's merge base `f6a211479`. In addition to the lock refresh,
upstream adds guarded Runner API fallback, responsible-person GitHub execution
identity, and recent-task ordering. The tested checkpoint `9007e4111` does not
contain those changes.
A non-worktree `git merge-tree` diagnostic confirmed conflicts in migration
metadata 02400245 and the journal, the OpenAPI route test, issue routes, and
native runner tool authority. Automatically merged heartbeat/executor paths
still require semantic verification; a textual auto-merge is not proof that
native chat authority and continuation behavior remain correct. No merge,
rebase, migration rewrite, or lockfile update was applied to the live worktree.
The existing live database must retain its applied migration history during
that future reconciliation. Current-source release qualification cannot be
claimed against the newer upstream revision until this work and its tests are
complete.
Independent review identified the concrete merged checks: retain both the
chat-specific native tool/attachment authority and upstream's guarded API
fallback; carry identity-context fields through the rewritten issue handlers;
test fresh and already-migrated databases; and verify broker-bound resumed
turns with different linked actors. Guest messages are currently quarantined,
and higher-trust runs omit their bodies and attachments. Upstream identity
initialization skips authorless comments and may inherit a continuation actor,
so guest-root and linked-A/guest/linked-B scenarios need explicit combined
identity/credential tests. This is an unverified integration boundary, not
evidence that credentials leaked in the tested branch.
## Slack accepted-upload receipt recovery
The bounded share lookup still had a process-interruption gap: after Slack
accepted a file, Paperclip could lose the returned file IDs before confirming
the share's real message timestamp. The follow-up records those exact IDs in
a private, attempt-bound `slack_file_upload_receipt` action immediately after
the successful upload response, before the eventual-consistency lookup. It
uses a per-call asynchronous context around ordinary `Thread.post`, preserving
the SDK's sent-message, typing, and history behavior.
An independent recovery lane performs only metadata reads for the saved file
IDs. It does not re-upload files, guess timestamps from filenames, or create
another model turn. Settlement requires the exact publication attempt,
endpoint bot/runtime/credential identity, conversation, channel/thread, and
current destination reach. It is endpoint-authorized bookkeeping for bytes
already accepted, not a newly authorized external-user send; file publications
have no original-principal anchor, and this change does not claim to add one.
Task controls and interactive cards are excluded. Historical attachment reuse
continues to authorize its own requesting principal separately.
An exact receipt can settle an unconfirmed publication automatically. After
an operator explicitly marks that same attempt delivered, recovery may only
enrich the missing provider identity/link; it must not repeat completion
effects or alter the confirmed timestamp. Retry/cancel/new-attempt changes
invalidate the old receipt. A conflicting existing message binding remains
unconfirmed. Receipts are omitted from normal endpoint Activity and publication
payloads. Older uploads without a durable receipt cannot be reconstructed by
this change.
Independent review caught two worker races before qualification: stale
selection could bypass a newly scheduled backoff, and held endpoints could
monopolize the bounded selection page. Claims now recheck eligibility and
attempts under the row lock; held endpoints and same-attempt streaming work
are excluded before the page limit.
The first real PostgreSQL run caught an additional timestamp-precision defect:
a server-default `updated_at` had microseconds, but the decoded JavaScript
timestamp used for equality had only milliseconds. The receipt remained
`received` and recovery returned zero. This is a production claim-path defect,
not a flaky timing assertion. Claims now use the already-locked row; malformed
or removed-endpoint receipts use a precision-safe state and semantic JSONB
comparison, with SQL null distinguished from JSONB null. The manual-confirmation
case also exposed untyped `jsonb_build_object` parameters; explicit casts fix
the PostgreSQL error before any deployment.
Supporting verification so far:
- All pinned-provider adapter and reconciliation-coordinator tests passed
**59/59**. Coverage includes reverse-order concurrent upload callbacks,
callback failure without a second upload, strict accepted-ID validation,
preserved SDK sent-message methods, independent reconciliation, and joined
shutdown for both successful and failed receipt lookups.
- The frozen tracked patch applied cleanly to pristine Slack adapter 4.39.0.
Its output exactly matches the installed module, SHA-256
`094eafb219f99546c5189a28e6c25b228034cc6a589edca63ed09a09d7ca42ea`.
The lockfile was not modified; this is patch reproducibility, not a passed
frozen workspace installation.
- Fresh databases `chat_adapters_test_20260907_slack_receipt_01` and `_02`
exposed the timestamp and manual-confirmation SQL defects. After fixes,
`_03` passed both expanded database cases, including duplicate receipt
capture, a 25-row paused backlog, exact-message conflict, cancellation, and
identity-only manual-confirmation enrichment.
- Fresh `_04` passed **4/4** focused cases, adding two workers demonstrably
preselected behind a held credential lease, and channel reach revoked during
a held metadata lookup. Only one competing lookup ran, its new retry deadline
remained intact, and revoked reach produced neither a provider link nor a
second upload. The final malformed-row SQL-null variant landed afterward
and was included in the final full-suite gate below.
- The first full receipt suite passed 278/282. Its fake Slack transport wrongly
invoked upload acceptance before a definite-rejection hook, leaving a receipt
that also disrupted two later tests. The fixture now separates pre-acceptance
rejection from post-acceptance ambiguity; production acceptance handling was
not weakened. An unrelated Discord Gateway renewal also consumed a global
one-shot database fault intended for the Slack lifecycle test. That fault is
now bound to the exact lifecycle transaction's uncommitted terminal row,
proving rollback of both its comment and terminal state. Fresh `_05` passed
all **6/6** affected cases, including the final null-result receipt variant.
- Final fresh database
`chat_adapters_test_20260907_slack_receipt_full_02` passed **282/282** in
63.91 seconds (58.15 seconds of tests). Server TypeScript passed after the
final code and fixture changes. These are simulated-provider tests with
real PostgreSQL, not new live-provider or process-kill qualification. The
earlier frozen-install, upstream, browser-input, model-capacity, and Teams
gates remain open; this is not a whole-product readiness sign-off.
## Snapshot 15: receipt repair deployed; live ambiguity proof still open
Committed and pushed `9277e0dc5`. The isolated instance restarted with loaded
version `2026.831.0+411.git.9277e0dc5`; startup recovery was ready at
**21:36:00.332 UTC**. The health endpoint and `/CHA/apps` both returned 200,
and Discord Gateway connected. Slack, GitHub, Discord, and Telegram endpoints
remain active. Maya still uses `paperclip_runner` / `codex` /
`gpt-5.6-luna`; no global defaults or agent model settings changed. There were
no active Maya runs at restart. The webhook-only proxy stayed running on 3104,
and the Board remains private on 3103.
The live rare-path test—Slack accepts bytes, share identity is temporarily
unavailable, and durable metadata recovery later binds the real message—is
still unqualified. Snapshot 13's fast-path file evidence is not relabeled as
this new recovery-path evidence. Browser-input recovery remains unresolved,
and a fresh account-limit check still reports exhausted weekly Codex capacity
with no reset credit. No model-driven retry, historical-file resend, or Teams
live pass is claimed by this deployment. The final automated evidence for the
deployed source remains 282/282 database cases, 59/59 adapter/coordinator cases,
server TypeScript, and exact pinned-patch reproduction.

View File

@ -0,0 +1,138 @@
# Native chat live reach audit — 2026-09-07
## Scope
Server `074271e3fc4c2419c8894b7a564916ff54b90e32`, isolated Board
`http://127.0.0.1:3103`, company Chat Adapter E2E. Maya E2E remains
`paperclip_runner`, provider `codex`, model `gpt-5.6-luna`. The server's startup
recovery was ready before this exercise. Slack, GitHub, Discord, and Telegram
were active; Teams was not configured.
This is a live negative reach test, not a new model-response benchmark. The
Codex account was already returning `usageLimitExceeded`. No model-starting
prompts were sent while destinations were enabled, and no historical failed
run was rewritten or replayed to manufacture a successful result.
## Journey
Starting from Connectors → Browse → Manage, the linked Board operator disabled
only the existing authorized test destination in Settings, sent one message in
the provider's existing test conversation through the signed-in in-app browser,
and inspected Paperclip Activity and the provider. Returning to Settings proved
the disabled state persisted; the original setting was then restored.
| Provider | Disabled setting | Send time (UTC) | Observed result |
| -------- | ---------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Slack | `#pc-chat-live-0905b` | 17:56:10.018 | Activity: filtered, “Destination is not enabled in Paperclip”; no reaction/reply |
| GitHub | `cryppadotta/paperclip-chat-e2e-enabled` | 18:01:08.615 | Saved comment persisted after reload; signed webhook acknowledged at 18:01:10; content rejected before durable ingress; no reaction/reply |
| Discord | Clawd `#general` | 18:02:53.827 | Activity: filtered, same destination explanation; no reaction/reply |
| Telegram | Allow direct messages | 18:03:55.816 | Activity: filtered, same destination explanation; no reply |
Provider markers were `SLACK-REACH-DISABLED-0907-1256`,
`GITHUB-REACH-DISABLED-0907-1301`, `DISCORD-REACH-DISABLED-0907-1304`, and
`TELEGRAM-REACH-DISABLED-0907-1305`. The suffix is a unique test label, not a
precise send-time claim. GitHub's comment is
[issuecomment-5574211696](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5574211696).
## Durable cross-checks
| Provider | Delivery ID | Received → processed (UTC) | Normalized event retains marker? |
| -------- | -------------------------------------- | --------------------------- | -------------------------------- |
| Slack | `e80087f8-7a49-44bf-8ad0-cd45aa670fc1` | 17:56:10.579 → 17:56:11.340 | No |
| Discord | `3d520054-5e5b-4d40-b094-db0c51eba189` | 18:02:54.057 → 18:02:54.815 | No |
| Telegram | `9bb5dc04-9c17-4a8b-a458-306bbdd04d00` | 18:03:57.048 → 18:03:57.049 | No |
GitHub differs intentionally: `stageGitHubWebhookIngress` authenticates the
request, then checks repository enablement before storing the signed body.
There was no new ingress action or delivery row. Its unchanged Activity is
therefore not itself proof that a callback arrived; the server's HTTP 200 log,
persisted provider comment, disabled resource, and source-level admission gate
provide the cross-check. No raw webhook body was inspected or retained as
evidence.
From the 17:55:49.711 baseline through the final 18:05:41.790 read:
- Maya's total run count remained **86**.
- The company had **zero** new tasks, internal comments, or publications.
- All four endpoints were active, with direct-message settings restored true.
- Slack's original private test channel, Discord `#general`, and GitHub's
`paperclip-chat-e2e-enabled` repository were restored enabled.
- GitHub's separate `paperclip-chat-e2e-disabled` repository stayed disabled;
no other Discord channel was enabled.
## Experience findings
Functional outcome: existing-conversation reach revocation worked in these four
live cases. It did not wake the native agent, retain refused message text, or
publish externally. The provider test markers are intentionally retained in
the disposable test conversations.
Activity originally displayed only “Sep 7, 2026” for every event. During this
exercise it was impossible to distinguish same-day deliveries, queue updates,
and retries from their visible time. The follow-up UI change uses the shared
date-time formatter with seconds, semantic `time` elements, and the exact
server timestamp on hover. The updated Telegram Activity was visually checked
in the running Board: the rejected event reads “Sep 7, 2026, 1:03:57 PM” and the
list remains readable without clipping at the observed desktop viewport.
Supporting checks for that UI change: focused date formatting and chat UI
contracts **29/29**; deterministic five-provider browser suite **5/5**, including
second-level display and exact timestamp attributes; UI TypeScript and token
gates passed. The deterministic suite uses mocked chat-provider endpoints and
does not count as live provider or native model evidence.
This is only the negative, existing-conversation portion of runbook C2. A fresh
message after re-enabling, fresh-task admission, access races during active
model execution, and post-quota recovery are not qualified by this exercise.
The final-source multi-provider release gate remains open.
## Follow-up: inspectable GitHub rejection
The initial GitHub result above exposed a diagnostic gap: an operator could
not distinguish an authenticated but disabled destination from a missing
webhook. The follow-up backend change records a content-free, non-replayable
filtered Activity receipt for a known disabled repository after signature,
installation, and endpoint checks. The delivery ID is hashed; no comment text,
author, conversation, or webhook body is retained. Unknown repositories and
invalid signatures still do not create this receipt.
A new regression reproduced the missing receipt before the change. After the
fix, the full chat integration suite passed **265/265** on fresh database
`chat_adapters_test_20260907_latency_09`. The added case covers invalid
signatures, three concurrent identical deliveries producing one receipt,
metadata-only Activity, no agent wake or ingress action, and no replay when
the repository is re-enabled.
### Live follow-up result
Restarted the isolated server at commit
`639bf1a20af9ca9afaecae126c12b7add714f19c`, with startup recovery ready before
the test. From Connectors → Browse → Manage GitHub → Settings, disabled only
`paperclip-chat-e2e-enabled`, then sent `GITHUB-REACH-RECEIPT-0907-1325` at
**18:24:59.455 UTC** in the same live test issue. The comment persisted after
navigating out to the repository's issue list and reopening the issue:
[issuecomment-5574403287](https://github.com/cryppadotta/paperclip-chat-e2e-enabled/issues/2#issuecomment-5574403287).
Paperclip Activity showed “message ignored”, “Destination is not enabled in
Paperclip”, and **Sep 7, 2026, 1:25:02 PM** (local time). The rendered row was
readable with no clipping at the observed desktop viewport. The initial
Activity visit preceded the new receipt appearing; revisiting the tab showed
it. This does not establish instantaneous live refresh or all transition
timings.
Delivery `fdec8621-2423-45b1-8349-83666a30f48e` was received at
**18:25:02.157 UTC** and processed at **18:25:02.158 UTC**. It had filtered
state, null conversation/principal, and only the hashed provider event ID,
event kind, disabled-resource ID, and content-retention-false reason. There
was no retained message text and no new GitHub ingress action.
From baseline **18:24:49.768** through **18:26:00.895 UTC**, counts remained
Maya runs **86**, company tasks **17**, internal comments **216**, and
publications **200**. Restored the enabled repository; the separate disabled
repository stayed off. Maya's persisted configuration remained
`paperclip_runner``codex``gpt-5.6-luna`.
Functional outcome: the original missing-receipt symptom is fixed in this
live case without admitting refused work or retaining its content. Experience
quality: this diagnostic path is now understandable from the Board; the
broader model-driven and Teams release gaps remain open.

View File

@ -0,0 +1,179 @@
# Live native chat reaction audit — 2026-09-07
## Environment and journey
Isolated Board `http://127.0.0.1:3103`, company Chat Adapter E2E, native Maya
E2E (`paperclip_runner` → `codex``gpt-5.6-luna`). The running backend was
`639bf1a20`; the updated UI was served through the development middleware.
These tests do not start model turns and do not qualify model quota recovery.
Using the signed-in in-app browser, added then removed only our thumbs-up
reaction on existing admitted human test messages in Slack's private
`pc-chat-live-0905b` thread and Discord's Clawd test thread. Inspected provider
state, connector Activity, and durable delivery metadata. No credentials,
message bodies, or model reasoning were copied into this evidence.
## First-cycle result
| Provider | Event | Browser action UTC | Received → processed UTC | Delivery ID |
| -------- | ------ | ------------------ | --------------------------- | -------------------------------------- |
| Slack | Add | 18:27:38.318 | 18:27:39.413 → 18:27:39.415 | `7d36d051-02e2-49b1-b53a-518bfc432403` |
| Slack | Remove | 18:28:12.516 | 18:28:13.527 → 18:28:13.529 | `e0595765-cb21-4aee-b699-6a13108409df` |
| Discord | Add | 18:27:43.071 | 18:27:43.593 → 18:27:43.594 | `d39b9424-8270-437a-91b8-1a39a4447437` |
| Discord | Remove | 18:28:18.539 | 18:28:18.818 → 18:28:18.821 | `015aceb0-c6b3-459f-a39a-81bb6902d97c` |
All four were processed, bound to the existing conversation, and had no error.
From **18:27:28.160** through **18:29:52.686 UTC**, counts remained Maya runs
**86**, company tasks **17**, internal comments **216**, and publications
**200**. Reactions were not interpreted as a message, answer, or authority.
The test reactions were removed; the bot's existing eyes reactions were not
changed.
## Activity refresh defect and fix
The initial live visit could show the earlier addition even after the removal
was durably processed. Chat detail queries inherited the global 30-second
fresh cache and had no periodic refresh; callbacks do not necessarily emit a
Board activity invalidation. The deterministic Slack browser regression failed
before the fix: a new fixture event never appeared within eight seconds while
Activity remained mounted.
Commit `2a554ce22` refreshes mounted Activity and Conversations queries and
their endpoint health every five seconds, with background polling disabled.
Freshness is zero on those operational queries so reopening a view also checks
current state. No new setting, visual token, or provider request was added.
The five-provider deterministic browser suite passed **5/5**, including
conversation-state, new-activity, and endpoint pause/resume changes without
reload or tab navigation. UI contracts passed **26/26**; UI TypeScript and token
gates passed. Those provider responses are mocked, separate from the live
evidence here. An intervening test run failed on an incorrect capitalized
`Waiting` selector; the actual existing badge text is `waiting`.
Live Slack retest used a fresh Board Activity view (tab 55) and the same test
message. Added thumbs-up at **18:33:54.321 UTC**; delivery
`88ab7e27-6ef9-4cb8-9f9b-400a81842026` was received at **18:33:55.201** and
processed at **18:33:55.204**. Without navigating or reloading that Activity
view, the new row and callback-health timestamp were visible at **18:34:07.073**.
Removed the reaction at **18:34:07.139**; delivery
`39b4632e-ee2a-4874-94d8-b5fa7d33d643` was received at **18:34:07.888** and
processed at **18:34:07.890**. The removal was also visible without
navigation at **18:34:31.803**, and both rows were visually inspected after
scrolling. These observation times establish automatic updates, not a measured
five-second end-to-end latency guarantee or a comprehensive transition audit.
## Repeated Discord cycle defect
Repeating the same Discord thumbs-up at **18:31:01.614** and removing it at
**18:31:19.087** produced no additional delivery rows. A later add at
**18:32:59.051** also produced none. This is distinct from the UI cache defect:
the database itself still held only the original add/remove pair. All added
test reactions were subsequently removed.
The provider event ID hashes the raw reaction payload. Discord's repeated
payloads had the same fingerprint, so event-kind plus payload distinguished
addition from removal but not a later occurrence of either. Qualification of
repeated Discord reaction cycles is failed at this checkpoint. The follow-up
must retain stable provider dispatch identity so actual duplicate delivery is
still deduplicated while distinct add/remove cycles remain auditable. No
history row was fabricated or replayed to claim a pass.
The follow-up adapter revision `paperclip-discord-v5` preserves the Gateway
session fingerprint, shard, event type, and sequence with the exact raw packet.
Only a one-way session fingerprint is carried, never the resumable session ID.
A packet-scoped WeakMap and a guarded, synchronous packet-handler wrapper keep
identity intact when discord.js buffers startup events. Suppressed SDK events
cannot leave a stale identity for the next event. Resumed duplicate dispatches
keep their identity; a new READY session receives a different fingerprint.
The wrapper is restored during shutdown and missing pinned hooks fail closed.
After synchronizing the installed package with the tracked patch, its complete
reverse dry-run passed and focused adapter/runtime tests passed **110/110**,
including buffered processing, suppressed callbacks, replay, session replacement,
and hook restoration. An initial reverse check differed only in the formatting
of the existing `ensureRootThread` helper; no semantic change to that helper was
needed. Live repeated-cycle qualification still requires the restart below.
## Telegram prior-generation reaction defect
In the same signed-in browser, added thumbs-up to the native file-reading reply
at **18:39:26.594 UTC** and removed it at **18:40:13.685**. Telegram showed the
reaction, but Paperclip recorded neither event. That provider message
`417200359:43` belongs to completed DM generation **5**, conversation
`3f13f43b-b9a7-44d9-9b8c-846ce77b0305`.
As a control, reacted to the newer native image-reading reply at
**18:40:25.370**. Its message `417200359:45` belongs to active generation **6**,
conversation `2b080821-103d-478e-b421-462014db7b30`. Delivery
`ef99baa0-19f9-43e2-bd17-ec3fc3426a5f` was received at **18:40:25.557** and
processed at **18:40:25.559**, with no error. Removed this test reaction at
**18:40:49.340**; delivery `31f43dcc-2d99-4f13-b6ba-f9dbced3dcf5` was received
at **18:40:49.533** and processed at **18:40:49.535**. No message was sent and
no bot acknowledgement was changed.
The reaction handler chose the newest conversation before checking the exact
message link, so an older generation's message was silently dropped. The new
regression failed before the fix with zero rows instead of four. The fix
resolves the conversation through its exact, company/endpoint/thread-scoped
message link before choosing a generation. Existing current reach, principal
authorization, runtime fencing, and conversation-state checks still apply.
The first focused PostgreSQL reaction run passed **9/9**; this is local
supporting evidence, pending live retest on the restarted backend.
## Follow-up integration checkpoint
The full PostgreSQL chat integration suite passed **266/266** on fresh migrated
database `chat_adapters_test_20260907_latency_13`, including distinct Discord
cycles, exact replay suppression, new-session identity, old Telegram DM
generation ownership, wrong-thread rejection, and revoked DM reach. Server
TypeScript and `git diff --check` passed. The preceding full run passed 265
cases and failed only because the new test used an unsupported `toHaveSize`
assertion; it was corrected to inspect the Set's size before this clean run.
The lockfile was not changed. The only newly fetched upstream commit,
`392ab26b1`, changes that file alone and was not applied under the explicit
instruction to leave it untouched.
## Final live retest on the reaction fix
Commit `dde176bbc` was pushed and the isolated server restarted after verifying
zero active runs. Snapshot 10 started at **19:18:20.078 UTC**, with loaded server
version `2026.831.0+396.git.dde176bbc`; startup recovery reached ready and the
Discord Gateway connected. Existing recovery-blocked history was not edited.
The same signed-in browser repeated two thumbs-up add/remove cycles on each
original test message. Every event below was processed once with a null error.
| Provider | Event | Browser action UTC | Received → processed UTC | Delivery ID |
| -------- | -------- | ------------------ | --------------------------- | -------------------------------------- |
| Discord | Add 1 | 19:18:51.109 | 19:18:51.537 → 19:18:51.539 | `324d147a-6ace-44fe-9952-529a02bfaced` |
| Discord | Remove 1 | 19:18:56.075 | 19:18:56.336 → 19:18:56.338 | `d98047bd-9efb-48b4-98b5-ed79f2aea964` |
| Discord | Add 2 | 19:19:10.424 | 19:19:10.661 → 19:19:10.663 | `71185c1a-bf33-4b75-87d9-9ba530c417e1` |
| Discord | Remove 2 | 19:19:15.631 | 19:19:15.826 → 19:19:15.829 | `8e45ba6c-94f7-40cb-b4f3-d62a681fcfc7` |
| Telegram | Add 1 | 19:19:31.957 | 19:19:33.065 → 19:19:33.067 | `5e1a4207-cbb0-49ff-87c7-c400f2cde12c` |
| Telegram | Remove 1 | 19:19:52.642 | 19:19:52.854 → 19:19:52.856 | `e3494e32-9841-4f6e-894b-d1d65ac26eb0` |
| Telegram | Add 2 | 19:20:15.526 | 19:20:15.711 → 19:20:15.714 | `e41b4e64-750d-4ae0-a59d-66f37edba765` |
| Telegram | Remove 2 | 19:20:33.120 | 19:20:33.386 → 19:20:33.387 | `267118ca-5d62-4c57-8856-eeaf55f6400b` |
Discord's second pair appeared in its already-open Activity view by
**19:19:21.917**, without reload/navigation. Telegram's first pair appeared
after navigating from the catalog into Activity; no automatic-refresh claim is
made for that first pair. With that view kept open, its second addition was
visible at **19:20:28.587** and removal at **19:20:40.938**. Both providers'
latest rows were visually inspected. All four Telegram receipts belong to
completed generation 5, not the newer active generation 6.
Between **19:18:41.974** and **19:20:57.941 UTC**, counts stayed **86 Maya runs,
17 tasks, 216 comments, 200 publications**. All test thumbs-up reactions were
removed; existing bot reactions stayed intact. Slack, GitHub, Discord, and
Telegram endpoints remained active. These specific functional and Activity
freshness retests pass and supersede the failed reaction baselines above.
Replay/startup-buffer behavior has deterministic coverage, not an injected
live Gateway outage qualification.
The Maya agent was rechecked as `paperclip_runner``codex``gpt-5.6-luna`.
The current Codex usage tool still reports the general weekly limit exhausted;
no reset, billing change, or model-starting prompt was attempted in this batch.
The broader provider release gate remains open, including live native model
stress/recovery and Teams tenant/admin qualification.

View File

@ -0,0 +1,388 @@
# Native Paperclip runner chat qualification — 2026-09-07
## Scope and runtime
The live Maya E2E fixture was switched from legacy ACP/Sol to
`adapterType: paperclip_runner`, Codex provider, model `gpt-5.6-luna`.
The isolated instance has native execution enabled. Persisted run records
confirm `runtimeMode: native`, `driverKind: codex_app_server`, and the explicit
Luna model in the native execution input. This is not an inference from the
agent's display name. No global model defaults were changed.
The native runner uses its per-turn lifecycle. The legacy agent configuration's
reasoning-effort field is not propagated by the current native input contract;
these measurements must not be described as native low-effort measurements.
Terra has not been needed for the text cases below and has not been qualified.
Tests used the existing linked operator identities and existing private test
threads. Guest isolation and permission requirements were not relaxed.
## Why the earlier turns were slow
Earlier legacy runs spent most of their time in extra model/tool round trips,
not in the provider transport or queue. Examples of successful media turns took
150183 seconds while their actual tool execution totaled under two seconds.
Generic operational instructions also asked agents to repeat checkout, comment,
and status work already owned by the chat harness.
The new narrow external-chat response contract is enabled only after validating
the company, immutable agent, active endpoint/conversation, exact issue, inbound
comment lineage, known provider, and harness checkout. Governed, held, recovery,
truncated, and otherwise ambiguous contexts retain the normal workflow.
Self-contained replies need no redundant control-plane calls. Files and real
work still require the authorized tools and normal safety checks.
## Live native text results
After restarting with the latest-message fix, each provider received
“What is 61 + 8? Reply with only the number.” Telegram's wording additionally
made explicit that this was a new message. All four provider UIs showed `69`.
| Provider | Native run ID | Agent runtime | Send to publication acknowledgement |
| -------- | -------------------------------------- | ------------: | ----------------------------------: |
| Discord | `a37453a9-9a01-42bc-a6c2-73b1a1793761` | 11.169 s | 13.472 s |
| Telegram | `b950314c-1a62-4046-8db7-2dd31f1fc27a` | 12.288 s | 14.743 s |
| Slack | `9001a484-ce26-489f-99f9-26446d979447` | 11.015 s | 13.554 s |
| GitHub | `7ba05f32-5cb4-4477-b3a5-1c07d537a8e9` | 11.493 s | 16.467 s |
Agent runtime is persisted `finishedAt - startedAt`. The final column is the
browser send timestamp to Paperclip's provider publication acknowledgement,
not a measured client-render latency. Run-row queue delays were 811 ms.
These are small local qualification samples, not production percentiles or an
SLA. Images, files, investigation, and externally delayed callbacks can take
longer.
### Correctness failure found and fixed
The first native Telegram turn answered an old task-title instruction instead
of the current arithmetic question. Its run was
`63477f30-2f08-4644-9ec8-516ecbde2b89`. The wake comment was correct; the native
structured title and completion objective repeated the old imperative.
Verified external-chat turns now use neutral structured native task fields.
The canonical task title/description remain background context. Completion
contracts target the latest message, and coalesced comments become ordered
criteria. Resumed eligible chat turns use the safe compact context selector.
The succeeding Telegram run above returned the correct current answer.
### Live burst/queue test
Three messages were submitted rapidly in each provider's existing thread:
requests for `ALPHA-0907`, `BETA-0907`, and `GAMMA-0907`. In all four provider
UIs, the first run answered ALPHA and the following run answered BETA and GAMMA
together. The latter run's persisted wake IDs contained both pending messages.
No requested marker was omitted and no duplicate final answer was observed.
| Provider | First run | Coalesced follow-up run |
| -------- | -------------------------------------- | -------------------------------------- |
| Discord | `948adf45-9264-4060-84fd-66dcdb2ffb5b` | `1e4b610e-a61e-4054-b3b3-adb1c2b6d241` |
| Telegram | `61044c8b-a6ab-4f7a-afa5-41aa3239b00c` | `efb8fee1-15ad-4b83-bd62-aa6a8a7e1895` |
| Slack | `838b739d-0361-4a01-bc3f-48703b65d426` | `bccb60a2-4f52-4153-b1e9-62354b8dbe27` |
| GitHub | `abed454d-5e7c-45d1-a4b1-80b152beb160` | `8ddd5631-54f2-4c3b-b3d2-41dbd8002fa9` |
Pending messages wait for the current turn before their run is materialized;
the run-row queue metric alone does not include this intentional wait.
A subsequent ten-message Discord test reached `requestedCount: 10` with eight
inline comments. The first held turn was
`d7f361e3-0631-4ffe-975f-a331b65016ec`; follow-up
`fd08101d-66a7-4a79-b485-bfb3ff2816b7` could not access the scoped reader in its
older resumed provider session and did not produce a complete answer. This is
a failed qualification, not a ten-message success. Discord briefly throttled
the browser's rapid sends; the remaining messages were sent normally and all
ten were durably accepted before the follow-up run.
The final checkpoint fingerprint is versioned by both tool schema and
advertisement policy, including the stable, binding-gated reader. It also
distinguishes local versus remote tool sets, rejects different managed
execution workspaces, and permits projectless continuation only when both
run-local workspace placeholders and repository descriptors match. Native
overflow turns use neutral task framing and explicitly opt into the reader;
legacy adapters retain their existing authenticated API fallback.
The live retry on the new checkpoint contract was blocked by account capacity:
Slack run `6299ba19-d7ea-4182-b7b8-c401d722821e` received an actual Codex
`turn.failed` with `codexErrorInfo: usageLimitExceeded`. It was a 10,402-character
message (the Slack composer would not send the initial 17,122-character draft),
with four values spanning the truncated inline body. The final reader fix is
therefore locally verified but **not live requalified**. A paced Discord retry
also hit the same provider-capacity boundary; its ten-message test was not
completed. Follow-ups on that recovery-owned task were rejected by the staging
ownership check; those recovery-path messages need live follow-up after capacity
returns, without loosening ownership checks.
The newly observed quota failure now has a closed classification from a
committed provider terminal, stops futile automatic retries, and produces a
safe provider-facing capacity explanation. Model prose, tool output, raw
provider error strings, account details, and reset URLs are not used as public
error content. This last change is locally tested; it has not been live tested.
### Post-live failure and attachment-isolation regressions
A database-backed restart test reproduced a further capacity-error bug: after
the provider terminal was committed but the controller stopped before its
callback, replay of that exact event lost the usage-limit classification and
scheduled another attempt. The duplicate-event observer now restores only that
in-memory classification. It does not repeat logging, activity, or publication.
Both first delivery and exact replay now persist `terminal_failure`, no next
attempt or automatic wake, a board-owned capacity recovery action, and exactly
one durable provider terminal. The test seeds the post-commit crash boundary and
uses a simulated provider with real PostgreSQL; it is not a process-kill test or
a new live Codex call.
The task UI maps the closed native capacity code to “Usage limit reached” and
explains when to retry, without exposing provider account details. The explicit
retry callback remains subject to the existing server checks. The earlier live
failure retains its original recorded error; it was not rewritten to fabricate
post-fix UI evidence.
Two additional database-backed file tests place an older decoy attachment on
the same task. With a newer current-wake attachment, only the newer storage
object is read and staged; with an omission-only current wake, no storage object
is read and no file is staged. This verifies isolation, not the ability to
retrieve or resend a historical attachment on request. That separate user
journey remains unqualified.
## Slack callback recovery
The exact `maya-e2e-paperclip` app, `A0C03GA5FPU`, still had a verified Events
callback on the older `:10000` Funnel endpoint. One message arrived only after
approximately six minutes of provider retries. Its callback was changed in the
signed-in Slack configuration UI to canonical `:8443`, verified by a genuine
Slack challenge, and saved. The similarly named older app was not changed.
The app's Interactivity and existing slash-command callbacks were also updated
to the same canonical URL. A real `/maya-e2e-fjomcs status` invocation reached
the new callback. A native single-choice question rendered Red/Blue buttons;
clicking Blue reached the genuine interactive callback, updated the card to
“Answered: Blue,” and produced the native continuation answer `Blue`.
All three callback surfaces became `current`, and `callbacksNeedUpdate` became
false.
The isolated webhook-only proxy now preserves its allowlisted public HTTPS
origin, and the server trusts forwarding headers only from loopback. This also
fixes false stale-callback observations caused by rewriting the host to local
HTTP. Public Board requests and wrong-host requests returned 404; an unsigned
request to a known webhook returned 401. The Board remains private.
## Runner activity and files
Native activity is durably recorded in Paperclip's run-event path and consumed
by the task transcript UI. Focused tests cover native transcript projection,
polling, and task rendering. External publication remains a separate safe
projection: coarse lifecycle status and selected final answers. Raw reasoning,
tool names/arguments/results, credentials, and internal logs are not chat output.
The live Paperclip task UI was inspected: native turns show worked duration,
expandable tool activity, and the queued/delivered timestamps for burst inputs.
The corresponding provider thread contains the selected answers, not the
internal operational commentary.
Follow-up read-only audit on September 7 reconfirmed the live agent configuration
as `paperclip_runner` / `codex` / `gpt-5.6-luna`, and the four text-run records
above as `native` / `codex_app_server`. No global defaults were changed. Focused
projection, stream, run-publication, interaction-publication, and heartbeat
summary tests passed **82/82** across five files. No live model call was made for
this follow-up because the account quota remains exhausted.
The Board's rich native activity projection is not safe to forward wholesale:
its objects can include command output, targets, and research queries. Native
`report_progress` is also Board-only for chat-origin runs. External stream
chunking presents already-selected safe prose; it is not token-live Runner
reasoning. Any richer external activity would require a separate closed,
sanitized projection, not reuse of the Board transcript objects.
A separate native execution-input, question-bridge, file-handoff, and
same-conversation attachment-reuse recheck passed **20/20** across four files
after the Slack polling change. Its disabled-runner eligibility error was an
isolated test-fixture gate, not a failure of the active live instance. These
contract/DB checks do not substitute for new model-driven live turns.
The new runner does not have the legacy operational skill or a general
Paperclip API key. Consequently, the previous shell-helper file instructions
were not a valid native-runner qualification. Native runs now receive a scoped
`register_deliverable` tool for local files and run-bound staging descriptors
for incoming attachments. Registration means prepared, not delivered; the
existing audited publication path still owns provider delivery.
### Native media evidence
| Provider / case | Native run | Observed result |
| -------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Telegram, generated text file | `8047d0ca-aabf-42d7-b8a9-753a84edbade` | Actual `native-telegram-0907.txt` document, 23 bytes; exact content `NATIVE-TELEGRAM-0907-OK`, no newline. |
| Telegram, inbound text | `21e85d82-f13e-4e9a-9003-7f8f08834d36` | Read new 103-byte fixture and returned the correct unseen phrase `violet birch 82`; 21.099 s. |
| Telegram, image round-trip | `c7fa20f1-d2d5-4ec2-91b2-00293735e5d5` | Described the orange tabby, sofa, and plant, then returned an actual photo; visually opened and inspected. 38.885 s. |
| Discord, generated text file | `d97f9736-f4be-417b-b38e-cf452f22f245` | Actual `native-discord-0907-c.txt` attachment and inline content preview; exact 24 bytes, no newline. 45.663 s. |
| Slack, generated text file | `71e4a887-21b1-4ecb-8b00-8a875aef04d6` | Actual `native-slack-0907-b.txt` file with preview in the original thread; exact 22 bytes, no newline. 38.576 s. |
| GitHub, generated file fallback | `cf8c2192-4643-4983-905e-2258c0a4162b` | Canonical `native-github-0907-b.txt`, exact 23 bytes, no newline. GitHub explicitly reported private-task storage and that this App cannot upload file bytes into comments. 43.761 s. |
| Discord, combined incoming text/image retest | `7f6cd1db-321b-4a02-a764-8db7496b1e19` | Exact phrase `violet birch 82`, correct cat/green-eyes/plant description, and actual returned PNG; 54.778 s. |
| Slack, combined incoming text/image retest | `9bcc22ea-14dd-4e57-ac12-c22dad7b2f95` | Exact phrase and correct cat/sofa/plant description in the final answer, with returned image visibly rendered in the original thread; 55.855 s. |
The returned Telegram JPEG matched the received image's 221,327 bytes and
SHA-256 `1d22f8c026abf16ff0dde087d6c46a3b4a41978cfb4cee62c62e159e5550ce8a`.
The inbound attachment was `b00400f3-7166-4e9c-af49-6a6091d783cd`; the
run-originated outbound attachment was `c238a817-fd79-4608-8db3-efd958efe1ee`.
Telegram may compress a newly uploaded photo, so this compares the received
provider image with the returned file, not with the original local PNG.
### Failures discovered during native qualification
- Native direct mode originally stripped all dynamic tools. The first Discord
file run `29ab85fc-e13a-4bbf-aa99-5d31094b0ba7` could create a file but could
not register it. The direct-mode bridge now permits only the server-supplied
file handoff capability, not arbitrary semantic/governance tools.
- Existing resumed Codex threads still lacked that newly added tool even when
it was passed to `thread/resume`. Discord run `567d3ae4-7627-4805-a925-b84c194eb58b`,
Slack run `ec6e140b-828c-4d77-ad6d-e441bb4e0ff4`, and GitHub run
`bba6a25d-c417-4360-a6ef-e161b5858a68` exposed this. A persisted tool-contract
fingerprint now rejects incompatible checkpoints before compact prompting;
fresh provider sessions receive the complete context and tool set. The
successful Discord/Slack/GitHub retries in the table above used this fix.
- A native status-decision wake could overwrite `chat:discord` provenance when
coalescing, preventing terminal publication and leaving “working” visible.
Exact status-decision metadata is now separate and preserves the original
verified chat source; unmarked/unrelated sources do not get that treatment.
- Corrupt or incompatible checkpoints previously rotated the session ID but
retained a resume-only prompt. The constructor now rebuilds full task and
wake context. File-only comments also retain a current completion criterion
instead of falling back to an older task title.
- Discord's first combined incoming text/image test, run
`07a0237a-f271-4add-9889-591a3cf0a515`, exposed a MIME parsing bug:
`text/plain; charset=utf-8` was rejected by the attachment allowlist. The
image arrived, but Luna substituted an older generated text file. This is
a failed content-correctness test, despite successful image delivery.
- Slack's equivalent run `812c7ea0-5341-4d55-9290-6c3d0bfe2f09` read both
attachments correctly in private activity, then omitted the requested
phrase and description from its semantic completion summary. The final
answer contract now explicitly requires the requested answers in that
summary; publishing private commentary is not the fix. Both multimodal
cases passed the unchanged live request after the MIME and current-attachment
guidance changes, as recorded above. Terra was not needed. Each returned
image exactly matched its provider's incoming bytes and SHA-256: Discord
2,111,878 bytes, `7693966f6c2b4aaebf9e46359f715fdaede021346bcd926078bb331b1dddc3c1`;
Slack 2,088,249 bytes, `005f8dabdb19ef786c0e2e76695596d22c1d0bb53de374e0be209cc6d89851c9`.
GitHub browser qualification briefly encountered a different active signed-in
account (`forgottendev`). The existing account switcher restored `cryppadotta`;
the stale page's optimistic comment was not treated as a successful delivery.
After the native Telegram file and image turns, the server-created local
staging slot was verified to contain zero bytes. Cleanup retains exact file
descriptors rather than deleting mutable paths. The final cross-process design
also skips live/unknown foreign owners and handles PID reuse. Opaque zeroed
directories remain per server restart; a process reuses its own slots, bounded
by its peak concurrent staged attachment count, not by sequential turns.
## Verification checkpoint
- Full chat integration suite on fresh PostgreSQL database
`chat_adapters_test_20260907_latency_03`: **263/263**.
- Repeated full chat integration on fresh
`chat_adapters_test_20260907_latency_04`: **263/263**.
- Final full chat integration on fresh
`chat_adapters_test_20260907_latency_06`: **264/264**, including Discord MIME
parameters and durable attachment-omission notices. The intervening `_05`
run exposed two fixture scheduling races; the cold-start and restart tests
now synchronize/seed their intended crash boundary without weakening their
acknowledgement-budget or revoked-access assertions.
- Repeated final full chat integration on fresh
`chat_adapters_test_20260907_latency_07`: **264/264** after the final native
compatibility, framing, and capacity-error changes.
- Adapter utility and ACP execution tests: **261/261**.
- Focused server/native/UI transcript tests: **206/206**, before the subsequent
native file-handoff changes.
- Capability inventories/contracts regenerated from the changed operational
skill; drift checks pass and validator self-tests pass **4/4**.
- Deterministic connector browser suite: **5/5** (provider APIs are mocked in
this suite; the live evidence above is separate), repeated after the native
file and reader changes.
- Native latest-turn, checkpoint fallback, and heartbeat context tests:
**51/51** after the file-only/current-context fixes.
- Native handoff/executor tests: **145/145**; Codex driver tests: **65/65**.
- Server TypeScript and runner build (including Rust binary and generated
contract/replay checks) passed, repeated after the generated skill contracts.
- Final reader/storage/authority tests: **20/20**, including five reader DB
cases and a failed receipt write that rolls back the exact storage object.
- Final executor/checkpoint/reader/native framing tests: **168/168**.
- Inline/overflow and legacy-adapter contract tests: **284/284**, plus the
independent native overflow-framing test **1/1**.
- Final Codex driver suite: **65/65**. Shared, UI, adapter-utils, and server
TypeScript checks passed. The operational skill validator passed.
- Final combined native runtime, reader, file-handoff, framing, capacity-error,
adapter, heartbeat context, attachment-type, and operational skill regression
run: **552/552**. Capability inventory and generated-contract drift checks
passed again. These are focused tests, not a claim that the workspace-wide
test/build gate passed.
- Post-live capacity replay, attachment-isolation, control-plane port, native
executor/reader, safe publication, and task UI checks: **256/256** across
seven focused suites. The replay test failed before the observer fix by
scheduling a new attempt, then passed. Server/UI TypeScript and UI token
gates passed. These simulated capacity cases do not replace the blocked
live quota-recovery retest.
Broad workspace tests are not claimed green. Teams still needs the real Microsoft 365
tenant/admin setup and has not received equivalent native live qualification.
GitHub App file-byte uploads remain an explicitly disclosed private-task
fallback. Provider quota recovery, the final long/burst reader changes, and
historical attachments outside the current wake still require live qualification;
this document is not a production-readiness sign-off for all providers/features.
## Same-conversation file resend follow-up
Code review after the native live file tests found a real capability gap:
current-wake staging safely omitted older files, but native direct chat had no
bounded way to resend an earlier attachment. The follow-up adds
`list_chat_attachments` (paged metadata only) and `reuse_chat_attachment`
(exact-byte server-side copy into a new current-run attachment and final-response
selection). It does not expose storage locations, reopen general task tools, or
permit an older file to substitute for unavailable current-turn input.
Both operations verify the current native run, immutable endpoint agent,
conversation, destination reach, principal membership, and exact admitted
inbound or confirmed published file lineage. Reuse repeats authorization on
idempotent replay, rejects ask-mode mutation, and records source/new IDs and
SHA-256 in receipts and Activity. Deleted or provider-edited/deleted source
messages are ineligible. The byte handoff also works for remote native targets
without returning a local path. The native tool-contract fingerprint advances
to v3 so old provider sessions cannot silently retain the pre-resend tool set.
Supporting verification after review fixes:
- Reuse/authority/resume suites: **35/35**; the three DB cases cover byte
identity, duplicate suppression, receipt preservation, equal-timestamp
pagination, exact-pair lineage, and access/source revocation.
- Codex driver fresh/resumed direct-mode tool filtering: **65/65**.
- Executor, file handoff, current-wake reader, and capacity regression:
**156/156**.
- Native chat prompt context tests: **29/29**.
- Server TypeScript and full runner build passed, including generated
protocol/capability/semantic drift checks, workflow traceability, Rust binary,
and replay golden checks. The lockfile was unchanged.
These are local simulated/DB tests. New live model-driven historical-file resend,
long/burst reader, and quota-recovery qualification remain blocked by the actual
Codex `usageLimitExceeded` response. Historical-file resend is not historical
file inspection: this tool intentionally returns no earlier file bytes to the
model. Teams and GitHub's private-task attachment fallback retain the limits
described above.
### Historical-file discovery and bounded storage follow-up
Commit `3e932de55` fixes a narrower discovery defect: selecting the newest
publication before excluding deleted or edited provider messages could hide an
older, still-valid publication of the same attachment. Listing now filters
invalid lineages before choosing a candidate. An explicit request using the
older known source-comment pair could already succeed; the defect was not a
blanket inability to reuse that file.
The same follow-up requires a provider message ID and publication timestamp for
confirmed outbound lineage, scopes inbound joins to the exact endpoint and
conversation, validates cursor UUIDs before querying, and bounds storage reads,
writes, and cleanup. A write that completes after its timeout schedules cleanup
of that exact newly written object.
The expanded package-local database suite passed **5/5**, covering valid older
lineage, unconfirmed publication rejection, malformed cursors, stalled writes
and late cleanup, byte identity, idempotency, and source/access revocation.
Server TypeScript passed. This is supporting local verification, not a new
live model-driven resend or quota-recovery pass; those remain unqualified.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
# GitHub private attachment authority
This records the bounded implementation and qualification boundary, not a claim
that arbitrary private GitHub files are downloadable by an installation App.
## Supported authority
GitHub documents installation access tokens for the exact
[issue-comment GET](https://docs.github.com/en/rest/issues/comments#get-an-issue-comment)
with existing Issues or Pull requests read permission. Its
`application/vnd.github.full+json` representation includes both the original
body and rendered HTML. The corresponding
[review-comment GET](https://docs.github.com/en/rest/pulls/comments#get-a-review-comment-for-a-pull-request)
requires Pull requests read and uses
`application/vnd.github-commitcomment.full+json`.
Those are legitimate fixed-repository comment reads. They do not document a
general private-attachment download API. GitHub's
[attachment documentation](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files)
distinguishes anonymous public uploads from repository-gated private uploads;
its [private attachment change](https://github.blog/changelog/2023-05-08-more-secure-private-attachments/)
explains why knowing the original private URL is insufficient.
## Narrow implementation
1. Only attachment references from an admitted, exact provider comment receive
a version-2 locator with the original body SHA-256. Existing four-field
locators remain anonymous-only; replay does not invent missing provenance.
2. An anonymous 401/403/404 may trigger one exact-comment GET using the existing
installation App, fixed `api.github.com`, no query, and no redirects. PAT,
cookie, user-token, unbound-installation, and custom-host fallbacks are absent.
3. The authenticated response must match comment ID, repository, issue/PR,
review-root when applicable, and the original body hash. Exactly one anchor
must contain one image targeting the same asset UUID on
`private-user-images.githubusercontent.com`, with a sole JWT query absent
from the original body. Its link must be either the original asset URL or
exactly its image URL. Both forms share one candidate count; duplicate,
mixed, or conflicting same-asset renderings fail closed. HTML parsing is
inert and bounded.
4. The signed image target is ephemeral. Download requests never receive App
credentials or cookies. Existing HTTPS/public-address pinning, redirect
allowlisting, byte/MIME validation, 20-second file and 60-second download-batch
budgets remain. The batch budget is not a hard total admission deadline.
5. Current principal, runtime credential generation, destination, conversation,
issue, and original input authority are checked before network access, after
download, and under locks with attachment registration. Storage I/O is outside
governance transactions; explicit revocation after storage removes the new,
unregistered blob. No provider response HTML or signed query enters the
delivery ledger, model context, or logs.
## Remaining gap and truthful UX
The real private text fixture under `/user-attachments/files/31948982/` remained
unavailable to anonymous intake; its provider browser anchor remained the
original file URL. The image-specific canonical mapping above does not invent
a signed generic-file endpoint. Such files remain a current-input
`download_unavailable` omission: no imported bytes, no claim of inspection, and
no substitution of an older task file. Activity currently shows that closed
omission rather than asserting that every 404 specifically means “private.”
Outbound is separate: the official
[GitHub CLI uploader](https://github.com/cli/cli/blob/trunk/internal/attachments/client.go)
allows OAuth, personal-access, and fine-grained personal-access tokens, not App
installation tokens. Paperclip keeps the private-task output-file fallback and
does not acquire extra repository permissions or impersonate the browser user.
## Qualification gate
Contract tests prove the real SDK's installation-token exchange and fixed
guarded comment GET, exact body/source binding, old-locator compatibility,
restart reconstruction, credential-free bytes, malformed/ambiguous rendering
denial, and revocation during download/storage. They are not live provider proof.
On the signed-in provider browser, upload a new private image to the existing
authorized test issue/PR comment, ask Maya to inspect that exact image, and
compare the stored hash/bytes with the fixture. Verify that no signed URL or
HTML is persisted. Repeat with a private text file; unless GitHub actually
provides a separately reviewed supported representation, it must still report
unavailable without substituting another attachment. Then test a review-comment
image and a changed/deleted source. Do not make the repository public to obtain
a passing result.
### Closed diagnostics for provider qualification
A first live private review-comment image was not imported; a signed anchor in
the browser is not evidence of the App REST response. The product now emits
only a closed `attachmentDiagnosticCode` beside the endpoint, issue, and
delivery IDs in the existing rejection log. Codes distinguish App authority or
request failure, exact source/body mismatch, missing rendering, unsupported
generic files, ambiguous/denied mapping, and a valid same-UUID signed-image
shape without the required original source anchor. In particular,
`github_attachment_canonical_signed_anchor_only` detected and denied an exact
signed anchor/image pair in the diagnostic-only deployment.
No response HTML, URL, JWT query, token, or provider error details enter these
diagnostics. SDK-wrapped errors retain only exact whitelisted codes with bounded
cause traversal; unknown errors collapse to a closed request-failed code.
Durable current-input omissions and agent prompts still use only
`download_unavailable`.
At 09:57:09 UTC on September 8, the actual App path emitted that signed-anchor
diagnostic for the newly admitted private review-comment image. This proved the
shape behind the unchanged source/body/repository/review-thread fences. The
bounded follow-up accepts exactly one such pair, with identical link/image
URLs and the same private-host/path/UUID/JWT checks. No additional host or
credential authority was added. Contract and PostgreSQL restart tests cover
both accepted forms, credential-free bytes, mixed/duplicate rejection, and
unchanged current-access/revocation checks. Successful live byte intake and
agent inspection were still unqualified at that diagnostic checkpoint.
### Live main-conversation image and generic-file check
At **17:12:44.815 UTC**, root used the signed-in GitHub browser to upload a
new image into the existing authorized test PR's main conversation. The
repository was visually confirmed **Private** and its visibility and App
permissions were not changed. `PRIVATE-IMAGE-LANDING-0908` asked the agent to
inspect only that newly attached image, without suggesting its visual content.
The current-input attachment imported as PNG, **2,111,878 bytes**, SHA-256
`7693966f6c2b4aaebf9e46359f715fdaede021346bcd926078bb331b1dddc3c1`.
An independent read-only audit rehashed the stored blob and verified the exact
comment/body/asset locator and wake-comment binding. The source-body hash
matches GitHub's CRLF normalization, not a rendered-page reconstruction.
The native `gpt-5.6-luna` run took **20.846 seconds** and recorded two artifact
view events. The final response accurately described the orange tabby, pale
green eyes, and indoor background; root inspected the rendered reply and image.
The final arrived **26.303 seconds** after submission. Working and final
operations each used one attempt and updated the same provider comment.
At **17:14:02.540 UTC**, `PRIVATE-FILE-LANDING-0908` uploaded a fresh synthetic
private text file. The prompt did not reveal its shape, color, or count.
The current-input action recorded exactly one `download_unavailable` omission,
zero imported or generated attachments, and no image-view event. Luna
truthfully reported that this exact new file was unavailable and did not
invent or reuse values. Its final arrived in **21.116 seconds**.
Scoped delivery, action, wake, run, event, result, comment and publication
checks found no persisted signed URL/JWT query or provider-rendered HTML.
Original query-free author `<img>` syntax is not provider-rendered HTML.
This is a scoped persistence audit, not a whole-database or browser-log claim.
These checks used server 48 and qualify the private **main-conversation**
image path and honest generic-file omission. Generic private files and native
outbound uploads remain unsupported.
### Live review-comment image after restart
On server 49, root replied to the existing authorized inline review thread,
uploaded the fixture again as a **new provider asset**, and submitted
`PRIVATE-REVIEW-IMAGE-LANDING-0908` at **17:25:14.894 UTC**. The final reply
arrived in that same review thread at **17:25:45.922 UTC**, **31.028 seconds**
later. The native Luna run used **23.822 seconds** and correctly described
the cat, pale green eyes, pink chair and plant. The rendered response persisted
after a normal browser refresh; the task and conversation remained open.
Independent diagnostics verified the exact new review comment, original review
root, current Paperclip comment, source-body digest, and new asset UUID. The
stored attachment again rehashed to the **2,111,878-byte** fixture SHA above.
The supplied source matches the locator digest after GitHub CRLF normalization.
There was one wake-associated run, no omission, two artifact-view events, and
one attempt per working/final publication, both targeting the same provider
review comment. Scoped persistence checks again found no signed-target/JWT or
provider-rendered response HTML, and no internal identifier in the final text.
This extends live proof to new private images in **both** main conversations
and review threads on the deployed authority implementation. It is not a
changed/deleted-source test or an interrupted-download/revocation stress test.
### Live changed-source rejection
Root uploaded a fresh private image in the authorized PR conversation and
submitted `PRIVATE-SOURCE-CHANGE-0908` at **17:38:59.379 UTC**. Paperclip was
deliberately offline after a zero-active-run shutdown, so the original created
delivery failed without entering the local delivery ledger. Root edited only
that synthetic source comment at **17:39:33.981 UTC**, preserving its new image
URL and appending a revision marker. The edit delivery also failed while the
server was offline. Neither delivery had been admitted before restart.
After server 50 became ready, root used the existing App identity and GitHub's
supported [App webhook redelivery API](https://docs.github.com/en/rest/apps/webhooks#redeliver-a-delivery-for-an-app-webhook)
to redeliver **only the original created event**, once. The new signed
delivery reached Paperclip and retained the original source digest
`1ac24a2833edef198dd4d6dfa6155414f93dff5d6e01902f9ef65b6e7902244b`.
The current canonical comment instead hashed to
`6c47240d26bf98e6561479a9c01ac6c5e111766a46ba01182397aea4845c5514`.
The unchanged new asset did not override this mismatch.
The closed diagnostic was `github_attachment_canonical_body_mismatch`, before
signed-target selection. The original ingress action was processed and its
retained body was redacted. No edited ingress action existed. The exact
current input had one `download_unavailable` omission, zero imported or
generated files, and no artifact-view event. One native Luna run took
**14.881 seconds**. Its final publication used one attempt and arrived
**17.755 seconds after ingress**: “The exact new image is unavailable because
it could not be imported.” Root verified that visible answer in the provider
conversation; the task remained open. The deliberate outage is not counted as
ordinary response latency.
An independent scoped audit of 52 run events and the associated delivery,
action, source, wake, run, result and publication found no signed-target/JWT
or provider-rendered-HTML markers, and no internal UUID in the final output.
This proves rejection of a **changed body** for a real redelivered event. It
does not prove deleted-source or in-flight download revocation behavior.
Separately, the bot-created reply callback received a 502 before reaching the
instrumented local proxy or Paperclip. GitHub reported the exact configured
destination, a 0.1-second duration, no response headers and an empty body.
Adjacent original-redelivery and bot-edit callbacks used that same destination
and received 202. The bot-edit was correctly filtered as outbound/self; the
missing created callback was not. The pre-proxy transport cause is unconfirmed
and must not be described as harmless self-event filtering or a repaired bug.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
# Native runner final-output burst benchmark — 2026-09-08
## Result and scope
A credential-free provider fixture emits 16, 128, or 512 ordered output deltas
and completes immediately after one accepted semantic completion. The real
Rust runner and TypeScript controller persist, acknowledge, replay, and close
that turn. There is no model, provider network call, App database, or App
semantic-completion grace period in this benchmark.
Batching only the provider queue acknowledgement for an already-durable prefix
of at most 128 events reduced median provider-completion-to-visible-terminal
time by 1826%. Every individual PRP outbox save remains in place. Controller
event commits, wire acknowledgements, authority checks, and exact suspension
proof are unchanged. This is isolated benchmark evidence, **not a live chat
latency or model-quality qualification**.
## Measurements
All values are milliseconds, shown as median [minimummaximum], with **n = 3
per size per binary**. Baseline repetitions ran first, then candidate repetitions
on the same Mac. CPU scheduling, background work, filesystem caches, and I/O
load were not controlled. These ranges are observations, not confidence bounds
or p95 estimates.
| Deltas | Baseline: provider complete → visible terminal | Candidate: provider complete → visible terminal | Median reduction |
| ------ | ---------------------------------------------- | ----------------------------------------------- | ---------------- |
| 16 | 796 [787815] | 653 [642655] | 18.0% |
| 128 | 5,486 [5,4175,639] | 4,214 [4,1254,222] | 23.2% |
| 512 | 18,658 [18,63319,102] | 13,745 [13,55414,275] | 26.3% |
| Deltas | Baseline: safe close | Candidate: safe close | Baseline: visible + close | Candidate: visible + close |
| ------ | -------------------- | --------------------- | ------------------------- | -------------------------- |
| 16 | 116 [110116] | 109 [109109] | 912 [903925] | 762 [751764] |
| 128 | 179 [169179] | 159 [155173] | 5,665 [5,5965,808] | 4,369 [4,2844,395] |
| 512 | 4,122 [4,0614,355] | 4,057 [3,8684,604] | 23,013 [22,75523,163] | 17,802 [17,42218,879] |
The fixture emits its burst in 02 ms. At 512 deltas, the median Rust terminal
emission delay changed from 16,706 to 11,814 ms. Controller cursor commits and
committed event counts remain 24 / 136 / 520 at the three sizes; observed
controller saves remain 42 / 154 / 540. The benchmark does not instrument Rust
save counts or exact wire ACK counts and reports those as unknown.
The roughly four-second 512-delta close tail remains. The total median
visibility-plus-close reduction is 22.6% at that size; this patch does not solve
all cumulative controller/wire-ACK work. Drain and suspend receipt intervals
can overlap, so they must not be added together as independent serial costs.
## Artifacts and reproduction
Implementation and fixture:
- `packages/paperclip-runner/src/live/runnerd-final-output-burst.benchmark.test.ts`
- `packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs`
- Production change: `packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs`, `poll_executor_events`.
Measured binary SHA-256 digests:
- Baseline: `e33d464cba6766becf9fb536182976c0359a78e4250301a5c86874f8212c9963`
- Candidate: `8a61219d5b492b8bdff55600d25a8da818e5f66b095e68fbac40a8a9e7013370`
The baseline is retained locally at
`/tmp/paperclip-final-burst-cargo.YoIBsw/baseline-paperclip-runnerd`; the candidate
is `/tmp/paperclip-final-burst-cargo.YoIBsw/release/paperclip-runnerd`.
These temporary binaries are not repository artifacts. The candidate used the
optimized release profile in this isolated Cargo target, never the live target
or staging script. The live staged binary retained the baseline digest after
the comparison.
Exact local evidence filenames, under the ignored
`.paperclip-runtime/chat-adapters-live/runner-output-burst-benchmark-20260908/`:
- `baseline.metrics.jsonl`: selected closed metric fields exported from captured
`FINAL_BURST_BENCHMARK` stdout, execution session `62858`, 9/9 passed.
- `candidate.metrics.jsonl`: the corresponding export from execution session
`96555`, 9/9 passed.
These are metric exports, **not full shell/Vitest logs**. Full Vitest results
were captured by the execution tool: baseline 103.48 s, candidate 83.97 s.
Run from `packages/paperclip-runner`:
```sh
PAPERCLIP_FINAL_BURST_BENCHMARK=1 PAPERCLIP_FINAL_BURST_REPETITIONS=3 PAPERCLIP_FINAL_BURST_BINARY=/tmp/paperclip-final-burst-cargo.YoIBsw/baseline-paperclip-runnerd pnpm exec vitest run src/live/runnerd-final-output-burst.benchmark.test.ts
PAPERCLIP_FINAL_BURST_BENCHMARK=1 PAPERCLIP_FINAL_BURST_REPETITIONS=3 PAPERCLIP_FINAL_BURST_BINARY=/tmp/paperclip-final-burst-cargo.YoIBsw/release/paperclip-runnerd pnpm exec vitest run src/live/runnerd-final-output-burst.benchmark.test.ts
```
Without `PAPERCLIP_FINAL_BURST_BINARY`, the test selects the existing staged
runner (or the existing debug runner if none is staged). It never builds one.
Every invocation copies the selected binary into a private fixture directory,
checks its SHA before and after, and uses an explicit empty Codex home and no
provider credentials. Without the opt-in flag, all three cases are skipped.
Repetitions are bounded to 15.
## Preserved invariants and checks
- All synthetic deltas arrive in exact order, with no loss or duplicates.
- The declared semantic completion handler executes once.
- Durable committed source sequences are contiguous, logical effects occur
once, and runner/controller ACK cursors agree.
- Safe close requires the exact six-field identity in durable suspended state
plus a completed suspension command; the close deadline is unchanged.
- Reopening the same run does not execute the semantic tool again.
- A successor authority can reopen/read the same provider thread and close
with its exact identity. **It does not execute a second provider turn**:
the fixture's turn count remains one. Live consecutive-turn qualification is
separate. The output names this `sameProviderAuthorityReopen` and explicitly
reports `successorTurnExecuted: false`.
- An oversized or identity-conflicting suffix acknowledges only the prior
durable prefix. A failed durable save authorizes no ACK. An ACK failure
retains replayable receipts; if commit and ACK both fail, the original commit
error remains observable. No same-memory retry can treat an unsaved receipt
as durable.
Regression evidence: the original loop failed three of four focused batch
tests; the candidate passed all 18 durable-runner tests and all 217 Rust
library tests. The expanded crash/replay test also covers changed event data
after controller ACK removed the outbox copy. Independent review found no
blocker. Runner no-emit TypeScript checking, fixture syntax, formatting, and
`git diff --check` passed. The no-emit production configuration excludes test
files; actual benchmark executions provide the test-path verification.
```sh
cargo test --manifest-path runner/Cargo.toml --locked --offline --target-dir /tmp/paperclip-final-burst-cargo.YoIBsw -j 2 -p paperclip-runner-core --lib durable::runner::tests
cargo test --manifest-path runner/Cargo.toml --locked --offline --target-dir /tmp/paperclip-final-burst-cargo.YoIBsw -j 2 -p paperclip-runner-core --lib -- --test-threads=2
node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit
```
Next validation is a root-coordinated staged build and real native chat
comparison. Any future controller ACK/persistence optimization needs its own
crash-boundary and replay proof; this change provides no authority to relax
durable receipt, ordering, or suspension requirements.

View File

@ -0,0 +1,380 @@
# Chat implementation landing checkpoint — September 9, 2026
This working note can be deleted after both landing PRs merge and the remaining
live hardening is represented by its own follow-up PR.
## Durable snapshot
- Snapshot revision: `007399bcd207f33aee7b62d14cf7a854cb979eca`.
- Snapshot tree: `5ba0226bedf205102b01ab7fae5d8bdf98cb832f`.
- Immutable local recovery branch: `codex/chat-adapters-snapshot-20260909`.
- Parent: `9afdf3232d2ac781ce4af05350129a6a8c7e2eb2`.
- Captured all 29 modified/new implementation and qualification-document paths
using an alternate Git index. All 29 working-file hashes matched the snapshot.
The original checkout's HEAD and empty staging area were unchanged.
- Landing worktree: `/Users/dotta/paperclipai/branches/chat-adapters-landing-20260909`.
- Initial landing branch: `codex/chat-adapters-landing-20260909`.
- Origin master at snapshot: `5acf56658bff7eeb12438a6fdcae5f4d2fe1e90e`.
- Ignored live runtime, credentials, databases, generated packages, and the
protected runner binary were not added. Changed/new files passed the scoped
credential-marker scan. This is not a claim of a full repository secret audit.
## Separate lanes
The user explicitly authorized the separate landing worktree and superseded the
earlier no-new-worktree/no-PR-tending constraints for this lane. James owns
reconciliation, exactly two coherent stacked PRs under 500 changed files each,
fresh exact-head Greptile 5/5, required checks, and dependency-ordered merges.
The existing PR is https://github.com/paperclipai/paperclip/pull/13038; it had 526
changed files and conflicts at this checkpoint. Preserve its review context
where practical. Do not merge based on old review scores or narrow local tests.
James exclusively owns remote `codex/chat-adapters` updates while reorganizing
that PR. The original local branch must not push over the landing heads. Do not
modify the original checkout or live runtime from the landing worktree.
The root, Epicurus, and Boole continue live stress qualification and subsequent
hardening in the original checkout. Changes after the snapshot remain separate.
After both merges, reconcile the ongoing branch with merged master, preserve
newer fixes, and open a follow-up PR without reintroducing landed changes.
## Verification boundary
Snapshot evidence: Board attachment cohort 417/417; blocked-continuation cohort
36/36; Rust durable-runner cohort 36/36; plain server/UI/runner TypeScript checks
passed; token gates clean. The full transport cohort was still running. These
are focused checks, not current-head full repository or landing CI verification.
Server 78 remained running on port 3137. Its loaded version was
`2026.831.0+623.git.ea528f44c`; a dynamically read Git HEAD is not proof that newer
source was deployed. No live restart or protected binary replacement occurred
while creating the snapshot. Historical quarantined recovery evidence remains
untouched.
## Subsequent checkpoint — 13:52 UTC
Base PR [#13092](https://github.com/paperclipai/paperclip/pull/13092) is open
with 45 changed files. Master reconciliation has exposed additional native
goal/integrity and PRP-v2 warm-authorization/state-retention defects. James owns
their landing-only regressions; neither the initial PR head nor historical
Greptile reviews certify the corrected head. The current-master warm-upgrade
compatibility boundary must be explicit, not hidden by a fail-closed test.
Root's test-only `5232fb22b` is available for the second PR. The new Board
uncertain-write and late-semantic-result hardening remain post-snapshot work.
A newly observed live Discord close/recovery loop must be fixed and qualified
before the experimental connector PR merges; it is not cosmetic follow-up.
Maya is temporarily paused to contain that loop. No deployment occurred.
## Subsequent checkpoint — 14:02 UTC
The base is now 47 files at `3e7289cd4` (James owns publication and exact-head
checks). Its prior head's Greptile 5/5 does not certify this head. Full workspace
build passed in the isolated landing worktree; full tests/checks remain pending.
The post-snapshot semantic-result fix is included in the base via its exact
four-file delta, not a duplicate cherry-pick of the full snapshot-containing
commit. Root's local commits are `c76988f93` (runner) and `ae21fd9e2` (Board).
The real process-replacement test proves a **v2-capable current artifact** first
leased as v1 can retire its exact owner and negotiate v2 on fresh bootstrap,
preserving native cached state before a warm attach. It does not prove an old
binary upgrade: the existing restart closure retains its original artifact.
Same-lease reconnect remains v1; adopted owners have no automatic upgrade path.
Do not advertise this internal recovery proof as a new operator upgrade API.
Board qualification finished 350 focused units and 11 actual browser journeys.
Runner release qualification finished 27 composed tests, in addition to 227
serial source tests. The close/recovery defect has two clean failing regressions
and remains a merge gate for the top PR. All live runs remain deliberately
paused. The original live binary and lockfile are unchanged.
## Subsequent checkpoint — 14:35 UTC
The current published base is `46ef7ef03ca35a47d6ac2be9e2dd497b137d3b70`,
44 changed files. Its exact-head Greptile score is 3/5; the prior 5/5 scores
do not satisfy the merge gate. James is addressing the concrete review
findings and current-master compatibility fixtures in the landing worktree.
The full runner suite at that head was 1,881 passed, six failed, ten skipped;
focused corrected fixtures do not replace the required fresh full-suite run.
Master has advanced through `35fdc0c66`, including durable task recovery work
that the top PR must preserve rather than overwrite with the older snapshot.
The original checkout's full chat integration suite is now **860/860 passed**
on fresh database `chat_snapshot_full_20260909_root06`, through test/copy fixes
in `02dc80d1e`. This is not a landing exact-head or full-workspace result.
All earlier failed runs remain recorded in the qualification notes.
The last close/recovery crash window has a genuine failing regression:
restoring the old blanket native-recovery exemption dispatches one provider
attempt after a committed close, where zero are allowed. The replacement
records exact-run `required`/`admitted` admission evidence in the server-owned
runner profile and preserves historical, already-admitted recovery behavior.
Nine focused cases pass; the final full recovery suite and final review are
still pending. These tests compose real native preparation and the actual
restart classifier, not an operating-system process crash.
Server 78 remains unchanged and Maya remains paused. The qualified release
runner has been copied to a private, read-only QA path but has not been
activated. A fresh database backup and controlled cutover precede the next
live question/form/close and attachment-fallback qualification.
## Subsequent checkpoint — 15:18 UTC
Base `335b2ee52709afb3885d4d6ebb2a3ece4b5864d6`, 47 changed files,
received a fresh Greptile **5/5**, clean security review and fully successful CI
run `34367194680`. Its complete local runner suite passed **1,888 tests**, with
10 preexisting skips. The whole release Rust workspace passed with serial test
scheduling and unchanged deadlines. A default-parallel attempt still exceeded
the descendant-lineage fixture's five-second deadline under load and remains
recorded; it was not hidden by the isolated or serial pass.
Master subsequently advanced to `82f662656` (#1309313095, #13097). The base
now has a runner-transport merge conflict. The landing agent will finish
collecting its running broad 335 test result before changing source, then
reconcile and requalify the new head. The 335 approvals/checks do not authorize
merging a later head without fresh verification.
Top reconciliation must preserve master's execution recovery ordering and the
snapshot's physical-owner/usage fences. In particular, a Board reconciliation
on a chat-bound task cannot create both a generic pending successor and a
separate authorized failed-chat retry. The proposed typed single-owner receipt
keeps current chat source/access checks and existing idempotent retry identity;
non-chat behavior stays unchanged. Joined regression evidence is required.
Root deployed server 79 from local `3f2387073` and resumed Maya. Discord's
native question/choice/free-text flow passed live; Slack's true queue and native
Stop/fresh-follow-up passed. Fresh Discord close exposed an old-definition
registration incompatibility; GitHub's private-file Board fallback exposed an
unwanted passive-wait continuation and a misleading already-bound-file send
error. Repairs and final tests are in progress in the original checkout and
have not been pushed over the landing branch. Exactly two coherent PRs under
500 files and dependency-order landing remain the required structure.
## Subsequent checkpoint — 17:39 UTC
The user merged runner prerequisite [#13092](https://github.com/paperclipai/paperclip/pull/13092)
and explicitly required **two remaining chat PRs**; the runner does not count
toward those two. The chat foundation is [#13100](https://github.com/paperclipai/paperclip/pull/13100),
143 files at `1c3c34c9b5d8dcc0a732beefcb683712b1d9bf8b`. The integration remains
[#13038](https://github.com/paperclipai/paperclip/pull/13038), 370 files at
`21d3f81f990e419634df765043795e328dd8f6b9`, stacked on the foundation. Neither
has merged. The foundation does not mount routes or activate providers.
Foundation exact-head CI `34381883937` is fully green, including required
`ci / verify` and `ci / e2e`, build, release canary, workspace/general suites,
and serialized server suites. The exact isolated local workspace typecheck
also passes; its full local test/build chain is still running. Earlier
historical-migration fixture failures are preserved in the evidence ledger;
the corrected four-file database cohort passes 27/27. Tenant/delete/drift
matrix passes 2/2 and runtime/adapter lifecycle tests pass 119/119. Fresh
exact-head Greptile review is still missing after manual requests; resolved
prior findings and the old score do not satisfy that gate.
Integration CI `34381886310` passed every job except general-server shard 2/5
and its dependent aggregate. That shard passed 2,615 tests and failed four
warm-session checkpoint fixtures. The actual failure was an exact-value
assertion: the rejected persisted checkpoint is `null`, not `undefined`.
The assertion exception entered failure projection against a partial mock
database, masking itself as `runner.insert is not a function`. A temporary
diagnostic service mock exposed the original assertion and was then removed.
Landing-only test commit `66f16f244` changes only that refusal assertion and its
explanatory comment. The focused matrix passes 28/28, complete executor file
313/313, and plain server types pass; production authority is unchanged.
Both failed logs remain available in the ignored qualification directory.
Master advanced to `8cfd30fb0`, including composer Stop and task-control
simplification. An isolated three-way composition preserves those changes
alongside awaited Board submissions, retained uncertain drafts, exact private
comment attribution, and cache invalidation. Shared build, UI types and six
affected UI suites pass 395/395 on that preview. It is not yet the remote
integration head or full integration qualification. The final integration
must be updated after foundation merge and receive fresh gates again.
Both current chat PR file lists were checked: no wireframe images or HTML
galleries remain. The integration includes only three production provider
brand SVGs. Separately, a delayed first-seen pre-close source admission path
is under bounded service investigation in the source checkout; integration
merge is held pending that result. Historical live and full-service passes
do not certify that new edge, and no live checkout, dependency, runner binary,
or provider configuration was modified by this landing work.
## Subsequent checkpoint — 18:15 UTC
Foundation remains 143 files at `1c3c34c9b5d8dcc0a732beefcb683712b1d9bf8b`.
Its required exact-head CI is green, but the only current Greptile status still
says the 143-file change exceeds the automatic 100-file limit. Manual review
requests at 17:16 and 17:30 have not produced a fresh review. The historical
3/5 on `29c48d25` is not current approval; no merge or repeated request spam
has bypassed the gate.
The isolated exact-head local monolithic run stopped at a database fixture's
embedded-Postgres initialization failure: 107 database tests passed, 25 were
skipped by support probes, and one failed during initialization. The unchanged
full database cohort then passed 133/133 with serial file scheduling. The
resumed Codex adapter suite found a separate fixed-run-ID temporary-directory
collision (expected one staged home, found four). Its unchanged focused test
passes in a fresh owned temporary directory with a Git-discovery ceiling.
The first temporary-directory-only retry inherited the enclosing repository
and failed a Git fetch; that unsuccessful harness attempt is retained too.
Other workspace groups pass; serialized local suites and build are still
running. None of these resumed checks relabels the original monolithic run
as green. Logs are retained under the ignored `foundation-verify-PqC0e6`
qualification directory.
The integration privately includes root's `752d52a00` intake guard and
`d6724e057` shared chronology repair, including exact JavaScript-trim parity
for accepted commands. Its file count is now 371 against the foundation.
The root's final full-service and browser repeats remain separate pending
gates; earlier live or full-service passes do not certify the new chronology
edge. No wireframe images or HTML galleries were added; the only changed
image assets remain three production provider-brand SVGs. Historical
wireframe generator source and its archive note are not image artifacts.
The physical master-UI composition passes all 5,853 UI tests, in addition to
the previously recorded 395 affected tests and UI types. Its initial browser
cohort passed 32 chat cases but failed the unchanged process-adapter composer
Stop case. An owned SIGTERM exit had `exitCode: null`, which the executor
treated as zero while cancellation was still awaiting termination. The run
could therefore become Succeeded before the cancellation compare-and-set.
The narrow fix applies only to the process adapter. Overlapping Stop calls
join one owned in-memory attempt; executor settlement waits until that attempt
and its cancellation write settle. Failed-attempt evidence separately prevents
a graceful SIGTERM handler's zero exit from being called success, while a
later Stop can retry a still-owned live child. Existing terminal database
winners remain authoritative. Native adapters and other legacy adapters are
unchanged; this adds no durable cancellation or provider authority.
Actual-process tests cover signal and graceful exits, adapter exceptions,
termination/write failures, duplicate callers after child-map removal, delayed
results, a first failed Stop followed by a successful retry, and independent
clean-completion winners. Two graceful-failure counterexamples were retained
as genuine REDs before repair. The final selected Stop/paused-wait cohort
passes 29/29. The preceding full recovery run passed 246/248; its two paused
fixtures mixed PostgreSQL microsecond defaults with a later rounded JS run
clock, making the supposed source occur after admission. Explicit ordered
fixture timestamps preserve all production guards and negative assertions.
The final full recovery file passes 252/252 (124.53s) on a fresh database,
and plain server types pass. Independent review is clear at source SHA
`f23b50982a750a0fd8cfe1c79cf40eaeca7afa3456d377098d9aa20c5bf975d5`.
The final unchanged process browser journey passes 1/1 (50.1s test, 1.0m total)
on isolated port 3233: queued comment, actual composer Stop, refresh and
maintenance hold, explicit resume, subtree pause/cancel, and an unaffected
completed child. The inspected screenshot shows Cancelled and Stopped, with
the queued comment retained. This is deterministic process-adapter proof,
not the optional native fake-Codex case or a live-provider Stop claim.
The process fixture has no assistant transcript, so its Waiting for transcript
copy is not evidence about a model conversation. Mis-selected zero-test grep
attempts and a pre-test shared-memory allocation failure are retained as
harness failures, not product REDs or passes. Only positively owned retired
fixture clusters were restarted and normally stopped to reclaim their own
IPC segments; database directories remain, all other clusters and global
settings are untouched. Detailed logs and the final trace/screenshot remain
in ignored `integration-master-ui-Joeb8O` qualification artifacts.
### Foundation merged; integration final-base qualification
Foundation #13100 merged at 18:49:13 UTC as
`6abeb67334348dcb6fde2d591a27ffc7efc7118d`, after required exact-head CI,
current approval, resolved prior threads, and a fresh Greptile 5/5 explicitly
naming `1c3c34c9b5d8dcc0a732beefcb683712b1d9bf8b`. Its isolated full build
also completed successfully. Serialized local coverage completed across
144 files and 2,179 tests: the retained OpenCode environment timeout passes
unchanged with an owned empty XDG configuration; the remaining 24 files
pass 297/297. These resumed runs do not erase the earlier monolithic failures.
Integration head `ac71491df` received an exact-head 4/5 review. Its only
finding alleged same-agent unrelated-run toast suppression. The actual
producer includes `runId`; the suppression helper returns exact run membership
before its agent-only fallback, and the toast builder requires `runId`.
Greptile explicitly withdrew the finding after this call-chain evidence.
Six mounted WebSocket-to-cache-to-toast regressions on unchanged production
source pass, with both LiveUpdatesProvider files 53/53 and UI types passing.
The first added-test attempt was 52/53 because it incorrectly expected a
success toast; existing policy deliberately excludes successful-run toasts.
That fixture expectation was corrected without changing notification policy.
The ac714 CI Build job failed during runner verification before building:
1,897 tests passed, three failed, and three existing tests were skipped.
One retained-maintenance case hit the fixed terminal-result ACK deadline
with an older durable event backlog. Two later cases inherited that failed
fixture's intentionally sticky cleanup quarantine because their backend
domain names were shared. A causal regression reproduces that contamination;
unique immutable per-row fixture names let an independent case start while
the original domain remains quarantined. Its focused test and runner types
pass. No production quarantine reset or deadline relaxation is introduced.
The original ACK timeout remains a separate unresolved gate at this checkpoint.
The second chat PR is being rebased onto the actual merged foundation and
newer master changes. The shared transport union must retain `chat_sdk`, and
the deferred-wake test import union preserves both upstream and chat cases.
The post-base head still requires complete CI and a fresh exact-head 5/5;
neither this foundation merge nor the withdrawn finding authorizes the
integration merge.
### Final-base component qualification — 19:38 UTC
The private integration candidate is
`090cde5144b2eb5119d91336263bd462fe28d98e`, 379 changed files against
merged foundation/master `6abeb67334348dcb6fde2d591a27ffc7efc7118d`.
Its production bytes match `66cab99df`, whose isolated physical checkout
passed the full workspace build. Only reviewed portable test fixtures and
qualification records changed afterward. The protected original checkout,
lockfile, installed dependencies, runner binary, and live server were not
changed. Neither chat PR contains generated wireframe images or HTML galleries;
the three integration image paths are production provider-brand SVGs.
The primary ACK-loss repair now passes the actual delayed-fsync counterexample,
19 retained-maintenance cases, 80 controller cases, and package types. It
replays only an exact completed terminal receipt within the same owned
maintenance invocation. It joins retired connection processing before reading
evidence, rechecks authority after retirement, and cannot launch a provider.
The earlier same-domain fixture quarantine cascade is independently fixed by
unique per-row fixture identities, not by resetting production quarantine.
The first complete post-base runner attempt used the local default 17 Vitest
workers and retained 14 failures, 1,890 passes, ten existing skips, and five
reported unhandled errors. Two repeated failures were Darwin path aliases in
fixture expectations and filesystem hooks. Canonical fixture paths preserve
the original integrity and failure assertions. A separate fixture port
collision is handled only during bounded, ownership-safe preparation before
staging once. The two unchanged startup/installed-dependency timeout cases pass
in isolated files; their original exact scheduling causes remain unproved.
No timeout was increased and no assertion or security gate was skipped.
The subsequent exact-source check used `VITEST_MAX_WORKERS=1`. All 38 Node
contracts, 1,906 executed Vitest tests, and replay goldens passed; Vitest took
325.96 seconds total (308.75 seconds tests). Its ten unchanged exclusions are
three opt-in benchmarks and seven Linux-only executable/guardian cases. This
is explicit local isolated qualification, not a claim about GitHub's worker
count or default-CI behavior.
That same command then stopped with a Rust failure under the original default
Rust test concurrency. The descendant-lineage fixture missed its first
five-second completion check. Its retained state had processed 255 of 300
descendants, still active, before the terminal event. Two bounded 128-event
polls account for that prefix; persistence includes file/directory fsync.
No restoration or capacity assertion had yet run. The unchanged complete test
passes alone in 3.64 seconds. This demonstrates progress at the limit, not a
uniquely identified storage or scheduling bottleneck. All Rust source and test
files are byte-identical to merged master, and an earlier instance of this
deadline failure was already recorded above.
The entire unchanged release Rust workspace then passed with explicit
`--test-threads=1`: 533 top-level tests plus two executed subprocess-helper
checks, with no failures. The two helper declarations are ignored in the
parent harness because their owning tests invoke them explicitly. The
conformance check passed 1/1, replay parity passed 11/11, and the required
actual runner-to-HTTP authority suite passed 870/870 across three files.
These resumed component passes do not turn the original halted `check:all`
invocation into a pass. Its failed log remains alongside all subsequent logs
in the ignored `integration-base-verify-YAhDBQ` stage.
The isolated checkout stayed clean, its lockfile retained SHA-256
`822ecb8c7463689b2b6a09f5d262b85ae99a39b06e6461813e14410e62b2b8b6`,
and its privately built runner retained SHA-256
`ea9b3abfe98b5ba752ad492a1a6e413e4f6afd1e8b5da812902999e334f1452e`.
The next remote update must obtain its own required CI and fresh exact-head
Greptile 5/5 before integration merge. The previous 4/5 finding was withdrawn;
that withdrawal is not a fresh 5/5 for the new head.

View File

@ -0,0 +1,256 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { providerScreens } from "./platform-wireframe-data.mjs";
const root = dirname(fileURLToPath(import.meta.url));
const out = join(root, "wireframes-v2");
mkdirSync(out, { recursive: true });
const esc = (value) => String(value)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const tx = (x, y, value, size = 14, fill = "#000", extra = "") =>
`<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" stroke="none" ${extra}>${esc(value)}</text>`;
const ln = (x1, y1, x2, y2, extra = "") => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${extra}/>`;
const rc = (x, y, w, h, extra = "") => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ${extra}/>`;
const circle = (x, y, r, extra = "") => `<circle cx="${x}" cy="${y}" r="${r}" ${extra}/>`;
function baseSvg(width, height, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="${width}" height="${height}"/>${body}</svg>`;
}
function textLines(x, y, lines, size = 12, fill = "#666", gap = 20, extra = "") {
return lines.map((line, index) => tx(x, y + index * gap, line, size, fill, extra)).join("\n");
}
function button(x, y, w, label, primary = false) {
return `${rc(x, y, w, 48, primary ? 'fill="#000"' : 'fill="#fff"')}${tx(x + w / 2, y + 30, label, 14, primary ? "#fff" : "#000", 'text-anchor="middle" font-weight="600"')}`;
}
function status(x, y, label, state = "Ready") {
return `${circle(x, y - 4, 5, 'fill="#e6e6e6"')}${tx(x + 14, y, label, 12, "#000")}${tx(x + 178, y, state, 12, "#666", 'text-anchor="end"')}`;
}
function annotations(regions, mobile = false) {
return `<g data-region="annotations">${regions.map((region, index) => {
const radius = mobile ? 9 : 12;
return `${rc(region.x, region.y, region.w, region.h, 'fill="none" stroke="#d33" stroke-dasharray="6 4"')}${circle(region.x, region.y, radius, 'fill="#fff" stroke="#d33" stroke-dasharray="4 2"')}${tx(region.x, region.y + 4, index + 1, 12, "#d33", 'text-anchor="middle" font-weight="700"')}`;
}).join("\n")}</g>`;
}
function globalSidebar() {
const items = ["New Task", "Search", "Dashboard", "Inbox", "Tasks", "Projects", "Routines", "Artifacts", "Agents", "Skills", "Connectors", "Audit"];
return `<g data-region="global-navigation">${tx(24, 38, "Paperclip", 20, "#000", 'font-weight="700"')}${items.map((item, index) => {
const y = 78 + index * 46;
return `${item === "Connectors" ? rc(12, y - 28, 216, 38, 'fill="#e6e6e6"') : ""}${circle(32, y - 10, 6, 'fill="#e6e6e6"')}${tx(52, y - 5, item, 14, item === "Connectors" ? "#000" : "#666", item === "Connectors" ? 'font-weight="600"' : "")}`;
}).join("\n")}${tx(24, 744, "Acme Company", 14, "#000", 'font-weight="600"')}${tx(24, 772, "Dana · Admin", 12, "#666")}${ln(240, 0, 240, 800)}</g>`;
}
function topbar(crumb) {
return `<g>${ln(240, 60, 1280, 60)}${tx(264, 36, crumb, 14, "#666")}${circle(1240, 30, 16, 'fill="#e6e6e6"')}</g>`;
}
function setupContext(provider) {
return `<g>${tx(264, 96, "CONNECTORS", 12, "#666", 'font-weight="600"')}${rc(252, 116, 216, 40, 'fill="#e6e6e6"')}${tx(280, 142, "Connect", 14, "#000", 'font-weight="600"')}${tx(280, 190, provider, 14, "#666")}${tx(280, 238, "External setup", 14, "#666")}${ln(480, 60, 480, 800)}</g>`;
}
function detailContext(provider, active = "Settings") {
const items = ["Overview", "Settings", "Access", "Conversations", "Activity"];
const label = provider === "Microsoft Teams" ? "Teams" : provider;
return `<g>${tx(264, 94, " All connectors", 12, "#666")}${circle(280, 132, 18, 'fill="#e6e6e6"')}${tx(308, 138, `Maya on ${label}`, 14, "#000", 'font-weight="700"')}${items.map((item, index) => `${item === active ? rc(252, 168 + index * 48, 216, 40, 'fill="#e6e6e6"') : ""}${tx(280, 194 + index * 48, item, 14, item === active ? "#000" : "#666", item === active ? 'font-weight="600"' : "")}`).join("\n")}${ln(480, 60, 480, 800)}</g>`;
}
function heading(screen, step = "") {
return `${step ? tx(504, 90, step, 12, "#666", 'font-weight="600"') : ""}${tx(504, step ? 124 : 108, screen.title, 28, "#000", 'font-weight="700"')}${tx(504, step ? 152 : 136, screen.subtitle, 14, "#666")}`;
}
const setupData = {
Slack: {
bot: "Maya → Slack bot @maya", identity: "Workspace app · one bot identity",
delivery: "Direct verified webhook", deliveryNote: "Advanced: Paperclip relay or Slack Socket Mode",
secrets: ["Bot/OAuth token •••• 7K2M", "Signing secret •••• C19Q"],
steps: ["Create app from generated manifest", "Install app to workspace or Grid org", "Return token/secret or finish OAuth", "Invite @maya to allowed channels"],
verify: [["Bot + workspace", "Ready"], ["Signed event", "Ready"], ["Scopes + events", "Ready"], ["Channel membership", "Test next"]],
action: "Verify Slack connection"
},
GitHub: {
bot: "Maya → maya-paperclip[bot]", identity: "Chat purpose · GitHub App recommended",
delivery: "Signed GitHub webhook", deliveryNote: "Advanced: GitHub Enterprise Server API URL",
secrets: ["App ID 184205", "Private key •••• PEM", "Webhook secret •••• 93FW"],
steps: ["Create GitHub App from checklist", "Grant Issues + PR write; Metadata read", "Subscribe to comment/review events", "Install on selected repositories"],
verify: [["Signature ping", "Ready"], ["Bot self ID", "Ready"], ["Events", "Ready"], ["3 repositories", "Selected"]],
action: "Verify GitHub App"
},
"Microsoft Teams": {
bot: "Maya → Teams app Maya", identity: "Bot + app package · tenant installation",
delivery: "Public messaging endpoint", deliveryNote: "Client secret or federated identity · not both",
secrets: ["App ID •••• 9B2A", "Client secret •••• 18JD", "Tenant ID •••• 7F01"],
steps: ["Run Teams CLI create with this endpoint", "Choose tenant mode and auth method", "Get install link or app package", "Install to personal/team/group scope"],
verify: [["Entra + bot", "Ready"], ["Manifest", "Ready"], ["Endpoint", "Ready"], ["Tenant install", "Admin action"]],
action: "Verify Teams installation"
},
Telegram: {
bot: "Maya → Telegram @maya_helper_bot", identity: "Dedicated BotFather bot",
delivery: "Verified webhook", deliveryNote: "Polling is local-development only",
secrets: ["Bot token •••• A8KQ", "Webhook secret •••• H92P"],
steps: ["Create bot and identity in @BotFather", "Keep privacy on; allow group joining", "Set webhook URL + secret token", "Add bot to intended chats/topics"],
verify: [["getMe identity", "Ready"], ["Delivery mode", "Webhook"], ["Pending updates", "0"], ["Test chat", "Send next"]],
action: "Verify Telegram bot"
}
};
const settingsData = {
Slack: {
reach: ["Workspace · Acme", "#customer-support · Invited", "#product-feedback · Invited", "DMs · On"],
boundary: ["Root @maya → Slack thread", "One thread ↔ one Paperclip issue", "Bound replies need no mention"],
capabilities: ["Agent Sessions + native stream · On", "Block Kit actions + modals · On", "Files + emoji/reactions · On", "Slash commands · Off", "Ephemeral denials · On"],
security: ["OAuth workspace install", "Signature · Healthy", "Token rotation · Supported", "Socket Mode · Off"],
fallback: "Missing scope → disable feature + Reinstall with scope"
},
GitHub: {
reach: ["acme/api · Installed", "acme/web · Installed", "acme/legacy · Excluded", "GitHub.com"],
boundary: ["Issue or PR conversation ↔ issue", "Review comment thread ↔ separate issue", "Discussions · Not in launch"],
capabilities: ["Mention activation · On", "Receipt reaction · On", "One edited GFM progress comment", "Files → Paperclip links", "Labels/trusted authors · Advanced"],
security: ["GitHub App installation", "Webhook signature · Healthy", "Self-message suppression · Ready", "Code/tool access · Separate connection"],
fallback: "No stream/buttons/modals/DM → GFM text + Paperclip URL"
},
"Microsoft Teams": {
reach: ["Tenant · Acme", "Support team / General · Allowed", "Personal scope · On", "Group chats · On"],
boundary: ["Channel post + replies ↔ one issue", "DM/group chat ↔ active issue", "New task explicitly rebinds linear chat"],
capabilities: ["Mention-only · On", "RSC all messages/history · Off", "Adaptive Cards + task modules · On", "DM native stream · On", "Group/channel buffered output"],
security: ["Single tenant · Acme", "Federated identity · Healthy", "User.Read.All · Not granted", "DM history admin grant · Off"],
fallback: "No RSC → mention on each undelivered reply; targeted → DM/text"
},
Telegram: {
reach: ["Support group · Allowed", "Forum topic 381 · Allowed", "DMs · On", "Privacy mode · On"],
boundary: ["DM → one active issue", "/new or New task → fresh issue", "Group @maya/reply; forum topic stable"],
capabilities: ["Post/edit cadence · 3.1s group", "Native drafts in DMs · Off", "Inline buttons + URLs · On", "Files/media groups · On", "Ephemeral/modal/select · Unsupported"],
security: ["Verified webhook · Healthy", "allowed_updates · Restricted", "Flood control · Normal", "Bot-to-bot routes · Off"],
fallback: "Privacy-on unrelated traffic ignored; denial → reply/DM + link"
}
};
const interactionData = {
Slack: [
["Human", "Root: @maya investigate refund timeout", "Fresh root without @maya is ignored"],
["Ingress", "Verify signature · persist · ack < 3s", "Deduplicate event_id; resolve Ari + channel"],
["Binding", "Reply under root; claim Slack thread_ts", "Create one PAP issue assigned to Maya"],
["Turns", "Thread replies, files, buttons, modal", "Reauthorize every actor/action; queue overlap"],
["Output", "Native stream/edits + Stop → final", "Safe projection only; publication ID recorded"]
],
GitHub: [
["Human", "@maya in issue, PR, or review comment", "Existing GitHub object supplies the thread"],
["Ingress", "Verify X-Hub-Signature-256 + delivery", "Resolve installation, repository, and actor"],
["Binding", "Object/thread key ↔ one PAP issue", "PR conversation ≠ inline review thread"],
["Turns", "Comments continue; bot comments ignored", "No code access unless separate tool grant exists"],
["Output", "React + post/edit one GFM comment", "No token stream; links replace files/actions"]
],
"Microsoft Teams": [
["Human", "Channel root @Maya · or DM/group message", "Conversation type selects the boundary"],
["Ingress", "Verify bot activity + tenant/member", "Persist, scope-check, resolve Paperclip actor"],
["Binding", "Channel post thread or active conversation", "Create one PAP issue; explicit New task in DM"],
["Turns", "Replies, files, Adaptive Card/task module", "Mention/RSC delivery and current permissions apply"],
["Output", "DM native stream; group/channel buffered", "Targeted → DM/text fallback; safe output only"]
],
Telegram: [
["DM", "First message → active issue; /new resets", "New task inline button is equivalent"],
["Group", "@maya activates; reply-to-Maya continues", "Privacy-on unrelated traffic is not consumed"],
["Forum", "message_thread_id ↔ one PAP issue", "Create/manage topics only with explicit admin grant"],
["Ingress", "Verify secret/poll claim; dedupe update_id", "Check chat/user scope; persist; typing/reaction"],
["Output", "Throttled post/edit + inline callbacks", "Opaque callback IDs; reply/DM + link fallback"]
]
};
function setupDesktop(screen) {
const d = setupData[screen.provider];
const checkRows = d.verify.map((row, index) => status(532 + (index % 2) * 338, 642 + Math.floor(index / 2) * 28, row[0], row[1])).join("\n");
return baseSvg(1280, 800, `${globalSidebar()}${topbar(`CONNECTORS Connect ${screen.provider}`)}${setupContext(screen.provider)}${heading(screen, "Provider handoff")}
${rc(504, 172, 720, 72, 'fill="#e6e6e6"')}${circle(536, 208, 18, 'fill="#fff"')}${tx(568, 202, d.bot, 14, "#000", 'font-weight="700"')}${tx(568, 226, d.identity, 12, "#666")}
${rc(504, 264, 344, 132)}${tx(528, 294, "IN PAPERCLIP", 12, "#666", 'font-weight="600"')}${tx(528, 324, d.delivery, 14, "#000", 'font-weight="700"')}${tx(528, 350, d.deliveryNote, 12, "#666")}${tx(528, 378, "Public endpoint copied · deployment detected", 12, "#666")}
${rc(504, 412, 344, 172)}${tx(528, 442, "CREDENTIAL REFERENCES", 12, "#666", 'font-weight="600"')}${textLines(528, 472, d.secrets, 12, "#000", 26)}${tx(528, 558, "Values stay masked after save", 12, "#666")}
${rc(872, 264, 352, 320)}${tx(896, 294, "AT THE PROVIDER", 12, "#666", 'font-weight="600"')}${d.steps.map((step, index) => `${circle(912, 332 + index * 46, 12, 'fill="#e6e6e6"')}${tx(912, 336 + index * 46, index + 1, 12, "#000", 'text-anchor="middle"')}${tx(938, 336 + index * 46, step, 12, "#000", 'font-weight="600"')}`).join("\n")}${button(896, 510, 304, "Open provider setup ↗")}
${rc(504, 604, 720, 92, 'fill="#e6e6e6"')}${tx(528, 628, "VERIFICATION", 12, "#666", 'font-weight="600"')}${checkRows}
${button(504, 720, 136, "Save draft")}${button(964, 720, 260, d.action, true)}
${annotations([{x:496,y:164,w:736,h:88},{x:496,y:256,w:360,h:148},{x:864,y:256,w:368,h:336},{x:496,y:404,w:360,h:188},{x:496,y:596,w:736,h:108}])}`);
}
function settingsDesktop(screen) {
const d = settingsData[screen.provider];
const capRows = d.capabilities.map((line, index) => `${tx(896, 236 + index * 32, line, 12, index === 1 && screen.provider === "Microsoft Teams" ? "#666" : "#000")}${tx(1196, 236 + index * 32, index === 1 && screen.provider === "Microsoft Teams" ? "Grant " : "", 12, "#666", 'text-anchor="end"')}`).join("\n");
return baseSvg(1280, 800, `${globalSidebar()}${topbar(`CONNECTORS Maya on ${screen.provider} Settings`)}${detailContext(screen.provider)}${heading(screen)}
${rc(504, 168, 344, 168, 'fill="#e6e6e6"')}${tx(528, 198, "REACH", 12, "#666", 'font-weight="600"')}${textLines(528, 228, d.reach, 12, "#000", 25)}${tx(816, 312, "Edit ", 12, "#000", 'text-anchor="end" font-weight="600"')}
${rc(504, 352, 344, 184)}${tx(528, 382, "TASK BOUNDARY", 12, "#666", 'font-weight="600"')}${textLines(528, 414, d.boundary, 12, "#000", 27)}${tx(528, 510, "Default · provider-native and durable", 12, "#666")}
${rc(872, 168, 352, 232)}${tx(896, 198, "BEHAVIOR + CAPABILITIES", 12, "#666", 'font-weight="600"')}${capRows}${tx(1196, 378, "Change ", 12, "#000", 'text-anchor="end" font-weight="600"')}
${rc(872, 416, 352, 136)}${tx(896, 446, "SECURITY + DELIVERY", 12, "#666", 'font-weight="600"')}${textLines(896, 474, d.security, 12, "#000", 22)}
${rc(504, 568, 720, 80)}${tx(528, 598, "FALLBACK", 12, "#666", 'font-weight="600"')}${tx(528, 626, d.fallback, 12, "#000")}
${rc(504, 672, 720, 48)}${tx(528, 702, "Internal reasoning and tool traces are never published.", 12, "#666")}${button(1080, 672, 144, "Save changes", true)}
${annotations([{x:496,y:160,w:360,h:184},{x:496,y:344,w:360,h:200},{x:864,y:160,w:368,h:248},{x:864,y:408,w:368,h:152},{x:496,y:560,w:736,h:96}])}`);
}
function interactionsDesktop(screen) {
const rows = interactionData[screen.provider];
const rendered = rows.map((row, index) => {
const y = 218 + index * 98;
return `${rc(504, y, 720, 82, index === 2 ? 'fill="#e6e6e6"' : 'fill="#fff"')}${rc(520, y + 17, 104, 48, 'fill="#fff"')}${tx(572, y + 47, row[0], 12, "#000", 'text-anchor="middle" font-weight="700"')}${tx(650, y + 32, row[1], 14, "#000", 'font-weight="600"')}${tx(650, y + 59, row[2], 12, "#666")}${index < rows.length - 1 ? `<path d="M 860 ${y + 82} L 860 ${y + 98}"/><polygon points="860,${y + 98} 854,${y + 89} 866,${y + 89}" fill="#000" stroke="none"/>` : ""}`;
}).join("\n");
return baseSvg(1280, 800, `${globalSidebar()}${topbar(`CONNECTORS Maya on ${screen.provider} Interaction model`)}${detailContext(screen.provider, "Conversations")}${heading(screen)}${tx(504, 188, "NATIVE EVENT", 12, "#666", 'font-weight="600"')}${tx(650, 188, "PROVIDER + PAPERCLIP RESULT", 12, "#666", 'font-weight="600"')}${rendered}${tx(504, 732, "All paths use durable delivery, current authorization, one task binding, and safe outbound projection.", 12, "#666")}${annotations(rows.map((_, index) => ({x:496,y:210+index*98,w:736,h:98})) )}`);
}
function mobileHeader(label) {
return `${rc(0, 0, 375, 56)}${tx(16, 35, ` ${label}`, 14, "#000", 'font-weight="600"')}${tx(359, 35, "Menu", 12, "#666", 'text-anchor="end"')}`;
}
function mobileTitle(screen, phase) {
const shortTitles = {
"Connect Maya to GitHub conversations": "Connect Maya to GitHub",
"Install Maya in Microsoft Teams": "Install Maya in Teams",
"Microsoft Teams settings": "Teams settings",
"Microsoft Teams interaction model": "Teams interaction model"
};
const title = shortTitles[screen.title] ?? screen.title;
return `${tx(16, 84, `${screen.provider} · ${phase}`, 12, "#666", 'font-weight="600"')}${tx(16, 116, title, 20, "#000", 'font-weight="700"')}${tx(16, 142, screen.subtitle.length > 55 ? screen.subtitle.slice(0, 54) + "…" : screen.subtitle, 12, "#666")}`;
}
function setupMobile(screen) {
const d = setupData[screen.provider];
return baseSvg(375, 812, `${mobileHeader("Connectors")}${mobileTitle(screen, "Setup")}
${rc(16, 166, 343, 72, 'fill="#e6e6e6"')}${tx(36, 196, d.bot, 14, "#000", 'font-weight="700"')}${tx(36, 220, d.identity, 12, "#666")}
${rc(16, 254, 343, 92)}${tx(36, 282, "IN PAPERCLIP", 12, "#666", 'font-weight="600"')}${tx(36, 310, d.delivery, 14, "#000", 'font-weight="700"')}${tx(36, 332, d.deliveryNote.slice(0, 48), 12, "#666")}
${rc(16, 362, 343, 188)}${tx(36, 390, "AT THE PROVIDER", 12, "#666", 'font-weight="600"')}${d.steps.map((step,index)=>`${circle(44,420+index*30,9,'fill="#e6e6e6"')}${tx(44,424+index*30,index+1,12,"#000",'text-anchor="middle"')}${tx(64,424+index*30,step.length>40?step.slice(0,39)+"…":step,12,"#000")}`).join("\n")}
${rc(16, 566, 343, 72)}${tx(36, 594, "MASKED CREDENTIALS", 12, "#666", 'font-weight="600"')}${tx(36, 620, d.secrets.join(" · ").slice(0, 48), 12, "#000")}
${rc(16, 654, 343, 66, 'fill="#e6e6e6"')}${tx(36, 682, "Verification", 12, "#666", 'font-weight="600"')}${tx(36, 706, d.verify.map(row=>`${row[0]} ${row[1]}`).join(" · ").slice(0, 52), 12, "#000")}
${button(16, 744, 343, d.action, true)}
${annotations([{x:8,y:158,w:359,h:88},{x:8,y:246,w:359,h:108},{x:8,y:354,w:359,h:204},{x:8,y:558,w:359,h:88},{x:8,y:646,w:359,h:82}],true)}`);
}
function settingsMobile(screen) {
const d = settingsData[screen.provider];
return baseSvg(375, 812, `${mobileHeader(`Maya on ${screen.provider === "Microsoft Teams" ? "Teams" : screen.provider}`)}${mobileTitle(screen, "Settings")}
${rc(16, 166, 343, 104, 'fill="#e6e6e6"')}${tx(36, 194, "REACH", 12, "#666", 'font-weight="600"')}${textLines(36, 220, d.reach.slice(0,3), 12, "#000", 20)}
${rc(16, 286, 343, 116)}${tx(36, 314, "TASK BOUNDARY", 12, "#666", 'font-weight="600"')}${textLines(36, 340, d.boundary, 12, "#000", 20)}
${rc(16, 418, 343, 126)}${tx(36, 446, "BEHAVIOR + CAPABILITIES", 12, "#666", 'font-weight="600"')}${textLines(36, 472, d.capabilities.slice(0,4), 12, "#000", 19)}
${rc(16, 560, 343, 104)}${tx(36, 588, "SECURITY + DELIVERY", 12, "#666", 'font-weight="600"')}${textLines(36, 614, d.security.slice(0,3), 12, "#000", 19)}
${rc(16, 680, 343, 56)}${tx(36, 704, "FALLBACK", 12, "#666", 'font-weight="600"')}${tx(36, 724, d.fallback.length>49?d.fallback.slice(0,48)+"…":d.fallback, 12, "#000")}
${button(16, 752, 343, "Save changes", true)}
${annotations([{x:8,y:158,w:359,h:120},{x:8,y:278,w:359,h:132},{x:8,y:410,w:359,h:142},{x:8,y:552,w:359,h:120},{x:8,y:672,w:359,h:72}],true)}`);
}
function interactionsMobile(screen) {
const rows = interactionData[screen.provider];
const body = rows.map((row,index)=>{const y=166+index*112;return `${rc(16,y,343,96,index===2?'fill="#e6e6e6"':'fill="#fff"')}${rc(32,y+16,64,48,'fill="#fff"')}${tx(64,y+46,row[0],12,"#000",'text-anchor="middle" font-weight="700"')}${tx(112,y+30,row[1].length>37?row[1].slice(0,36)+"…":row[1],12,"#000",'font-weight="600"')}${tx(112,y+54,row[2].length>37?row[2].slice(0,36)+"…":row[2],12,"#666")}${index<4?`<path d="M 188 ${y+96} L 188 ${y+112}"/><polygon points="188,${y+112} 182,${y+103} 194,${y+103}" fill="#000" stroke="none"/>`:""}`;}).join("\n");
return baseSvg(375,812,`${mobileHeader(`Maya on ${screen.provider === "Microsoft Teams" ? "Teams" : screen.provider}`)}${mobileTitle(screen,"Interactions")}${body}${tx(16,754,"Durable · authorized · one task · safe output",12,"#666")}${annotations(rows.map((_,index)=>({x:8,y:158+index*112,w:359,h:112})),true)}`);
}
for (const screen of providerScreens) {
const desktop = screen.kind === "providerSetup" ? setupDesktop(screen)
: screen.kind === "providerSettings" ? settingsDesktop(screen)
: interactionsDesktop(screen);
const mobile = screen.kind === "providerSetup" ? setupMobile(screen)
: screen.kind === "providerSettings" ? settingsMobile(screen)
: interactionsMobile(screen);
writeFileSync(join(out, `${screen.id}-${screen.slug}.svg`), `${desktop}\n`);
writeFileSync(join(out, `${screen.id}-${screen.slug}-mobile.svg`), `${mobile}\n`);
}
console.log(`Generated ${providerScreens.length * 2} provider SVGs`);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,576 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = dirname(fileURLToPath(import.meta.url));
const out = join(root, "wireframes");
mkdirSync(out, { recursive: true });
mkdirSync(join(root, "screenshots"), { recursive: true });
const screens = [
{
id: "01", slug: "connectors-catalog", title: "Connectors", subtitle: "Connect tools and places where people talk to agents.", context: "Apps", active: "Connectors", kind: "catalog",
panels: [
["Slack", "Tools: 1 account", "Channels: Maya bot · Active", "Add account"],
["Microsoft Teams", "Tools: Not connected", "Channels: Available", "Connect"],
["Discord", "Tools: Not available", "Channels: Preview", "Connect"],
["Telegram", "Tools: Not available", "Channels: 2 bots", "Manage"],
["GitHub", "Tools: 1 app", "Channels: Available", "Connect"],
],
notes: ["Filter by Tools, Channels, or Connected.", "Slack, Teams, Discord, Telegram, and GitHub form the initial supported set.", "Maturity and deployment state control the available action."],
},
{
id: "02", slug: "connection-method", title: "Connect Slack", subtitle: "Choose how Slack and Paperclip should communicate.", context: "Apps", active: "Setup", kind: "choice",
panels: [
["Agent uses Slack", "Give selected agents Slack tools.", "Agents call Slack during Paperclip runs.", "Uses tool permissions and grants."],
["People talk to an agent", "Install one Paperclip agent as a Slack bot.", "Messages become Paperclip task turns.", "Uses channel identity and access rules."],
["Separate connections", "These methods do not share credentials.", "Choose the direction before setup.", "Recommended: channel connection"],
],
notes: ["Two directions are named before credentials are requested.", "The channel method binds one bot to one Paperclip agent.", "Credentials and permissions remain independent."],
},
{
id: "03", slug: "choose-agent-identity", title: "Choose the agent", subtitle: "This Slack bot will always represent one Paperclip agent.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 1 of 7 · Agent & identity",
panels: [
["Paperclip agent", "Maya · Support lead", "Active · Codex runtime", "Change agent"],
["Slack bot preview", "Maya", "@maya-support", "Avatar from agent profile"],
["One bot per agent", "Add another Slack app for another agent.", "Native mentions select the agent.", "No hidden dispatcher bot."],
],
notes: ["Only active, invokable agents can be selected.", "Provider bot identity is previewed beside the Paperclip agent.", "Multiple agents require multiple native bot identities."],
},
{
id: "04", slug: "provider-installation", title: "Install the Slack bot", subtitle: "Bring your own Slack app and verify every connection layer.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 2 of 7 · Provider installation",
panels: [
["1 · Create the app", "Open generated Slack manifest", "Install or reinstall to workspace", "Invite @maya-support to a channel"],
["2 · Save credentials", "Bot token · Secret reference", "Signing secret · Secret reference", "Values are hidden after save"],
["3 · Verify", "Bot identity · Passed", "Webhook signature · Passed", "Scopes · 1 action needed"],
],
notes: ["BYO app setup is the required release path.", "Secrets are stored as Paperclip secret references.", "Credential, signature, scope, and reachability checks are separate."],
},
{
id: "05", slug: "conversation-reach", title: "Choose where Maya listens", subtitle: "Allow exact resources and make activation behavior predictable.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 3 of 7 · Conversation reach",
panels: [
["Allowed channels", "#customer-support · On", "#product-feedback · On", "+ Add exact channel"],
["Thread activation", "Mention Maya in the channel root", "Bot opens thread + one issue", "Continue in thread without mentions"],
["Direct messages", "One task per Slack DM thread", "Proactive DMs: Off", "Linked users and guests allowed"],
],
notes: ["Resource ids, not display names, enforce reach.", "Root mention → native thread → one Paperclip issue is the thread-capable default.", "GitHub binds an existing thread; Telegram uses its stable chat or topic."],
},
{
id: "06", slug: "people-permissions", title: "Choose who people act as", subtitle: "Every external message receives a bounded Paperclip identity.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 4 of 7 · People & permissions",
panels: [
["Endpoint sponsor", "Dana · Company admin", "Provides a maximum authority envelope", "Change sponsor"],
["Linked people", "Act as their Paperclip user", "Current permissions checked each action", "Invite identity link"],
["Unlinked people", "Sponsored restricted guest", "May message this task and attach files", "Cannot govern, approve, hire, or reassign"],
],
notes: ["The endpoint sponsor is visible before activation.", "Linked users are reauthorized with current permissions.", "Guest authority is an intersection and excludes governance."],
},
{
id: "07", slug: "output-interactions", title: "Choose channel behavior", subtitle: "Expose useful progress without exposing Paperclip internals.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 5 of 7 · Output & interactions",
panels: [
["Acknowledgement & progress", "React with eyes when supported", "Safe milestones: On", "Update every 4 seconds at most"],
["Rich output", "Final text, approved files, cards", "Buttons, dropdowns, modals: On", "Unsupported: text + Paperclip link"],
["Overlapping messages", "Queue messages on this task", "Other modes: Burst · Debounce · Drop", "Concurrent mode requires explicit selection"],
],
notes: ["Milestones never include reasoning or raw tool traces.", "Every rich feature has a named text/link fallback.", "Queue is the default concurrency policy."],
},
{
id: "08", slug: "agent-routes", title: "Agent-to-agent routes", subtitle: "Let bots talk only through explicit directed routes.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 6 of 7 · Agent routes",
panels: [
["Agent routing", "Off by default", "Bot messages are ignored", "Enable with a directed route"],
["Allowed route", "Maya in #support → Quinn in #engineering", "Trigger: Native mention only", "Maximum hops: 2"],
["Loop protection", "Suppress self and revisited endpoints", "Suppress repeated causal fingerprint", "Keep immutable route audit"],
],
notes: ["A master default-off control prevents accidental bot loops.", "Routes are directed and resource-scoped.", "Hop, revisit, self, and fingerprint guards are mandatory."],
},
{
id: "09", slug: "review-activate", title: "Review and activate", subtitle: "Verify the bot, its authority, and a real Slack message.", context: "Apps", active: "Setup", kind: "wizard", step: "Step 7 of 7 · Review & activate",
panels: [
["Configuration", "Maya · @maya-support", "2 allowed channels · DMs on", "Sponsor: Dana · Guest profile: Restricted"],
["Required checks", "Credentials · Passed", "Webhook & signature · Passed", "Bot invited to #customer-support · Passed"],
["Live test", "1. Mention Maya in the channel root", "2. Bot opens thread + one issue", "3. Follow up there without a mention"],
],
notes: ["Review summarizes identity, reach, permissions, and behavior.", "The live test proves activation and subscription behavior.", "BYO completion enables activation; managed install is optional."],
},
{
id: "10", slug: "endpoint-overview", title: "Maya on Slack", subtitle: "See what is connected, whether it works, and what needs attention.", context: "Apps", active: "Overview", kind: "detail",
panels: [
["Endpoint", "Agent: Maya · Support lead", "Bot: @maya-support", "Workspace: Acme"],
["Health", "Provider credentials · Healthy", "Direct ingress · Healthy", "Last delivery · 2 minutes ago"],
["Activity", "18 conversations · 7 active tasks", "24 linked people · 3 guests", "1 failed publication"],
],
notes: ["Agent, bot, installation, and endpoint status stay together.", "Health separates credentials, ingress/relay, and delivery.", "Lifecycle controls sit near status; removal remains a danger action."],
},
{
id: "11", slug: "endpoint-access", title: "Access", subtitle: "Manage where the bot listens and who external people represent.", context: "Apps", active: "Access", kind: "table",
panels: [
["Resources", "#customer-support · Active", "#product-feedback · Active", "#private-escalations · Disabled"],
["People", "Ari S. → Ari Stone · Linked", "Jules P. → Sponsored guest", "build-bot → External bot · Routed"],
["Policy", "Sponsor: Dana", "Guest: Message + safe files", "Governance: Linked authorized users only"],
],
notes: ["Resource status and exact provider identity remain visible.", "People rows distinguish linked users, guests, and bots.", "Revoke preserves historical attribution while stopping future authority."],
},
{
id: "12", slug: "endpoint-behavior", title: "Behavior", subtitle: "Edit inbound, outbound, and interaction policies with fallbacks visible.", context: "Apps", active: "Behavior", kind: "detail",
panels: [
["Inbound", "Root mention → bot thread", "One issue per endpoint thread", "Existing thread / chat fallback shown"],
["Outbound", "Acknowledge: Reaction → Ephemeral", "Safe milestones + final output", "Stream: Native → Post and edit"],
["Capabilities", "Files · Supported", "Cards/actions/modals · Supported", "Deletes · Append tombstone"],
],
notes: ["Inbound settings name their task/run consequence.", "Outbound settings show provider fallback order.", "Saving creates a versioned policy with a change preview."],
},
{
id: "13", slug: "conversations-tasks", title: "Conversations", subtitle: "Every bot-owned external thread maps to one Paperclip issue.", context: "Apps", active: "Conversations", kind: "table",
panels: [
["#customer-support · Refund workflow", "PAP-1842 · In progress", "4 participants · 8m ago", "Subscribed"],
["DM with Ari Stone", "PAP-1839 · Waiting for input", "Linked user · 24m ago", "Subscribed"],
["#product-feedback · Import CSV", "PAP-1804 · Done", "Detached yesterday", "Open history"],
],
notes: ["Rows pair one external thread with exactly one endpoint-owned Paperclip issue.", "Filters cover active, waiting, failed, detached, and DMs.", "Detach preserves history and unlocks assignment."],
},
{
id: "14", slug: "deliveries-diagnostics", title: "Activity and deliveries", subtitle: "Diagnose accepted, ignored, retried, and failed external events.", context: "Apps", active: "Activity", kind: "table",
panels: [
["Inbound mention", "Applied · PAP-1842", "event Ev04…91 · deduped once", "122 ms"],
["Outbound final", "Retrying · Slack rate limit", "publication Pb18…40 · attempt 2", "Retry in 28 seconds"],
["Button action", "Denied · User not linked", "action Ac77…10 · acknowledged", "Open redacted details"],
],
notes: ["One ledger covers inbound, outbound, and interactive actions.", "Rows expose dedupe, attempt, timing, and task without payload secrets.", "Replay is idempotent and limited to eligible failures."],
},
{
id: "15", slug: "agent-channels", title: "Maya · Channels", subtitle: "Every place this Paperclip agent can be reached.", context: "Agent", active: "Channels", kind: "agent",
panels: [
["Slack · @maya-support", "Acme · 2 allowed channels", "Healthy · Root mention opens thread", "7 active tasks"],
["Telegram · @maya_helper_bot", "Support group + DMs", "Needs attention · Token expires", "3 active tasks"],
["Recent channel tasks", "PAP-1842 · Refund workflow", "PAP-1839 · Ari DM", "PAP-1827 · Product question"],
],
notes: ["Channels sits under Runtime in agent navigation.", "Endpoint cards retain platform identity, reach, health, and trigger policy.", "Add channel starts Apps with this agent preselected."],
},
{
id: "16", slug: "bound-task", title: "Refund workflow is failing", subtitle: "PAP-1842 · Externally bound to Maya on Slack.", context: "Task", active: "Task", kind: "task",
panels: [
["Slack · #customer-support", "Thread: Refund workflow", "Assigned agent locked to Maya", "Open Slack · Manage connection"],
["Ari S. · External participant", "The refund step is timing out again.", "Linked as Ari Stone", "8 minutes ago"],
["Maya · Agent output", "I found the failing retry boundary…", "Publication: Delivered to Slack", "Artifact: retry-analysis.md"],
],
notes: ["A source banner explains the binding and assignment lock.", "External attribution never impersonates a Paperclip user.", "The composer defaults internal; Send to channel is explicit and previewed."],
},
{
id: "17", slug: "identity-link", title: "Link your Slack identity", subtitle: "Confirm who you will act as when messaging Maya.", context: "Identity", active: "Link", kind: "link",
panels: [
["Slack identity", "Ari S. · Acme workspace", "Requested by @maya-support", "Expires in 9 minutes"],
["Paperclip identity", "Ari Stone · ari@acme.example", "Company: Acme", "Signed in"],
["After linking", "Future actions use current permissions", "This does not share Slack credentials", "You can revoke from endpoint Access"],
],
notes: ["Both identities and company are visible before confirmation.", "Authentication returns to the same single-use intent.", "Expired, used, revoked, and mismatch states fail safely."],
},
{
id: "18", slug: "self-hosted-relay", title: "Ingress for this instance", subtitle: "Use direct HTTPS or an outbound relay for a private Paperclip.", context: "Apps", active: "Overview", kind: "relay",
panels: [
["Direct HTTPS", "Recommended when Paperclip is public", "Provider sends to this instance", "Current: Not reachable"],
["Outbound relay", "Private instance opens one connection", "Encrypted bounded delivery envelopes", "Current: Connected"],
["Relay health", "Owner: chat-adapters-dev", "Heartbeat: 12 seconds ago", "Backlog: 0 · Key rotated 8d ago"],
],
notes: ["Mode comparison starts with detected reachability.", "Enrollment reveals a one-time secret only once.", "Health distinguishes relay receipt from Paperclip processing."],
},
{
id: "19", slug: "adapter-state-matrix", title: "Adapter and state matrix", subtitle: "One UI system covers provider shapes and operational fallbacks.", context: "Apps", active: "Reference", kind: "matrix",
panels: [
["Workspace apps", "Slack · Teams · Discord · Google Chat", "App registration + tenant + webhook", "Rich interactions and streaming vary"],
["Comments and messaging", "GitHub · Linear · Notion · Telegram", "Token/app + resource allowlist", "Thread and mention rules vary"],
["Phone, social, and email", "WhatsApp · Twilio · X · Resend · iMessage", "Sender identity + webhook", "Media, window, and rate limits vary"],
],
notes: ["Provider taxonomy drives setup fields without cloning the wizard.", "Capability rows name supported, fallback, and unavailable behavior.", "Shared states cover loading, empty, degraded, denied, rate-limited, revoked, and dead letter."],
},
];
const uiSurfaceSpec = readFileSync(join(root, "2026-09-03-chat-adapters-ui-surfaces.md"), "utf8");
const annotationMap = new Map(
[...uiSurfaceSpec.matchAll(/### (\d{2})[^\n]*\n\nPurpose:[^\n]*\n\n((?:\d+\.[^\n]*\n){5})/g)].map((match) => [
match[1],
match[2].trim().split("\n").map((line) => line.replace(/^\d+\.\s*/, "")),
]),
);
for (const screen of screens) {
screen.annotations = annotationMap.get(screen.id);
if (!screen.annotations || screen.annotations.length !== 5) {
throw new Error(`Expected five documented annotations for screen ${screen.id}`);
}
}
const esc = (value) => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
const text = (x, y, value, size = 14, fill = "#000", extra = "") =>
`<text x="${x}" y="${y}" font-size="${size}" stroke="none" fill="${fill}" ${extra}>${esc(value)}</text>`;
const multiline = (x, y, lines, size = 14, fill = "#666", gap = 24) =>
lines.map((line, index) => text(x, y + index * gap, line, size, fill)).join("\n");
function wrapWords(value, maxCharacters = 48) {
const lines = [];
let current = "";
for (const word of value.split(" ")) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > maxCharacters && current) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current) lines.push(current);
return lines.slice(0, 2);
}
const mobileSubtitle = (y, value) => multiline(16, y, wrapWords(value), 12, "#666", 16);
function desktopSidebar(screen) {
const appItems = screen.context === "Agent"
? ["Overview", "Instructions", "Skills", "Runtime", "Secrets", "Tools", "Channels", "Permissions"]
: screen.context === "Task"
? ["Inbox", "Tasks", "Projects", "Agents", "Apps", "Activity"]
: ["Connectors", "Review", "Setup", "Overview", "Access", "Behavior", "Conversations", "Activity"];
return `
<g data-region="navigation">
<rect x="0" y="0" width="240" height="800" />
${text(24, 40, "Paperclip", 20, "#000", 'font-weight="600"')}
${text(24, 72, screen.context, 12, "#666", 'font-weight="600"')}
${appItems.map((item, i) => {
const y = 96 + i * 48;
const active = item === screen.active;
return `${active ? `<rect x="8" y="${y - 24}" width="224" height="40" rx="4" fill="#e6e6e6" />` : ""}${text(24, y, item, 14, active ? "#000" : "#666", active ? 'font-weight="600"' : "")}`;
}).join("\n")}
${text(24, 760, "Acme Company", 14, "#000", 'font-weight="600"')}
${text(24, 784, "Operator", 12, "#666")}
</g>
<g data-region="topbar">
<line x1="240" y1="64" x2="1280" y2="64" />
${text(264, 40, `${screen.context} / ${screen.title}`, 14, "#666")}
<circle cx="1240" cy="32" r="16" fill="#e6e6e6" />
</g>`;
}
function annotations(regions, mobile = false) {
return `<g data-region="annotations">${regions.map((r, index) => {
const n = index + 1;
const cx = r.x;
const cy = r.y;
return `<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="4" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<circle cx="${cx}" cy="${cy}" r="${mobile ? 8 : 12}" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
${text(cx, cy + (mobile ? 4 : 4), n, 12, "#d33", 'font-weight="700" text-anchor="middle"')}`;
}).join("\n")}</g>`;
}
function desktopCard(x, y, width, height, panel, index) {
const [heading, ...lines] = panel;
return `<g transform="translate(${x},${y})" data-region="panel-${index + 1}">
<rect width="${width}" height="${height}" rx="8" />
${text(24, 40, heading, 20, "#000", 'font-weight="600"')}
${lines.map((line, i) => {
const yy = 80 + i * 40;
return `<line x1="24" y1="${yy - 16}" x2="${width - 24}" y2="${yy - 16}" stroke="#e6e6e6" />${text(24, yy + 4, line, 14, i === lines.length - 1 ? "#000" : "#666", i === lines.length - 1 ? 'font-weight="600"' : "")}`;
}).join("\n")}
</g>`;
}
function desktopGeneric(screen) {
const contentX = 280;
const width = 952;
const cards = screen.panels.map((panel, i) => desktopCard(contentX + (i % 3) * 312, 224, 288, 288, panel, i)).join("\n");
const step = screen.step ? text(contentX, 96, screen.step, 12, "#666", 'font-weight="600"') : "";
const actions = screen.id === "10"
? `<g transform="translate(952,104)"><rect width="120" height="40" rx="4" />${text(60, 25, "Test", 14, "#000", 'text-anchor="middle"')}</g><g transform="translate(1088,104)"><rect width="120" height="40" rx="4" fill="#000" />${text(60, 25, "Pause", 14, "#fff", 'font-weight="600" text-anchor="middle"')}</g>`
: screen.id === "09"
? `<g transform="translate(1040,104)"><rect width="168" height="40" rx="4" fill="#000" />${text(84, 25, "Activate channel", 14, "#fff", 'font-weight="600" text-anchor="middle"')}</g>`
: screen.kind === "wizard"
? `<g transform="translate(1088,680)"><rect width="120" height="40" rx="4" fill="#000" />${text(60, 25, "Continue", 14, "#fff", 'font-weight="600" text-anchor="middle"')}</g><g transform="translate(952,680)"><rect width="120" height="40" rx="4" />${text(60, 25, "Back", 14, "#000", 'text-anchor="middle"')}</g>`
: `<g transform="translate(1088,104)"><rect width="120" height="40" rx="4" fill="#000" />${text(60, 25, screen.id === "15" ? "Add channel" : "Save", 14, "#fff", 'font-weight="600" text-anchor="middle"')}</g>`;
const lower = screen.kind === "table"
? `<g transform="translate(${contentX},544)"><rect width="928" height="136" rx="8" fill="#e6e6e6" />${text(24, 32, "Selected details", 14, "#000", 'font-weight="600"')}${multiline(24, 64, ["Exact provider and Paperclip identifiers", "Current state, last event, and safe operator actions", "Sensitive payload values remain redacted"], 12, "#666", 24)}</g>`
: `<g transform="translate(${contentX},544)"><rect width="928" height="96" rx="8" fill="#e6e6e6" />${text(24, 32, screen.notes[0], 14, "#000", 'font-weight="600"')}${text(24, 64, screen.notes[1], 12, "#666")}</g>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- ${screen.id} · ${esc(screen.title)} · Desktop 1280×800 -->
<rect x="0" y="0" width="1280" height="800" />
${desktopSidebar(screen)}
${step}
${text(contentX, 136, screen.title, 28, "#000", 'font-weight="700"')}
${text(contentX, 168, screen.subtitle, 14, "#666")}
${actions}
${screen.kind === "wizard" ? `<line x1="${contentX}" y1="192" x2="1208" y2="192" /><line x1="${contentX}" y1="192" x2="${contentX + Number(screen.id) * 72}" y2="192" />` : ""}
${cards}
${lower}
${annotations([
{x: 272, y: 88, w: 944, h: 112},
{x: 272, y: 216, w: 304, h: 304},
{x: 584, y: 216, w: 304, h: 304},
{x: 896, y: 216, w: 320, h: 304},
{x: 272, y: 536, w: 944, h: screen.kind === "table" ? 152 : 112},
])}
</svg>`;
}
function desktopCatalog(screen) {
const rows = screen.panels.map((panel, i) => {
const y = 264 + i * 96;
return `<g transform="translate(280,${y})"><rect width="928" height="80" rx="8" ${i === 0 ? 'fill="#e6e6e6"' : ""}/><rect x="16" y="16" width="48" height="48" rx="8" fill="#e6e6e6" />${text(80, 32, panel[0], 14, "#000", 'font-weight="600"')}${text(80, 56, `${panel[1]} · ${panel[2]}`, 12, "#666")}${text(888, 48, panel[3], 14, "#000", 'font-weight="600" text-anchor="end"')}</g>`;
}).join("\n");
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect x="0" y="0" width="1280" height="800"/>${desktopSidebar(screen)}${text(280,128,screen.title,28,"#000",'font-weight="700"')}${text(280,160,screen.subtitle,14,"#666")}<g transform="translate(280,184)"><rect width="480" height="40" rx="20"/><circle cx="24" cy="20" r="8"/><line x1="32" y1="28" x2="40" y2="36"/>${text(48,25,"Search connectors",14,"#666")}</g><g transform="translate(784,184)"><rect width="424" height="40" rx="4"/>${text(16,25,"All Tools Channels Connected",14,"#000")}</g>${rows}${annotations([{x:8,y:64,w:232,h:408},{x:272,y:176,w:944,h:56},{x:272,y:256,w:944,h:472},{x:272,y:448,w:944,h:80},{x:1072,y:256,w:144,h:472}])}</svg>`;
}
function desktopTask(screen) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="1280" height="800"/>${desktopSidebar(screen)}${text(280,112,screen.title,28,"#000",'font-weight="700"')}${text(280,144,screen.subtitle,14,"#666")}<g transform="translate(280,176)"><rect width="928" height="88" rx="8" fill="#e6e6e6"/>${text(24,32,screen.panels[0][0],14,"#000",'font-weight="600"')}${text(24,56,screen.panels[0][1],12,"#666")}${text(24,76,screen.panels[0][2],12,"#666")}${text(888,48,"Open Slack",14,"#000",'font-weight="600" text-anchor="end"')}</g><g transform="translate(280,296)"><rect width="640" height="128" rx="8"/><circle cx="32" cy="32" r="16" fill="#e6e6e6"/>${text(64,32,screen.panels[1][0],14,"#000",'font-weight="600"')}${text(64,64,screen.panels[1][1],14,"#000")}${text(64,96,`${screen.panels[1][2]} · ${screen.panels[1][3]}`,12,"#666")}</g><g transform="translate(280,448)"><rect width="640" height="144" rx="8" fill="#e6e6e6"/><circle cx="32" cy="32" r="16" fill="#e6e6e6"/>${text(64,32,screen.panels[2][0],14,"#000",'font-weight="600"')}${text(64,64,screen.panels[2][1],14,"#000")}${text(64,96,screen.panels[2][2],12,"#666")}${text(64,120,screen.panels[2][3],12,"#666")}</g><g transform="translate(944,296)"><rect width="264" height="296" rx="8"/>${text(24,32,"Properties",20,"#000",'font-weight="600"')}${multiline(24,72,["Status · In progress","Assignee · Maya (locked)","Priority · High","Project · Support","Channel · Slack"],14,"#666",40)}<g transform="translate(24,232)"><rect width="216" height="40" rx="4"/>${text(108,25,"Detach channel",14,"#000",'text-anchor="middle"')}</g></g><g transform="translate(280,624)"><rect width="928" height="104" rx="8"/>${text(16,32,"Internal note",12,"#666")}<line x1="16" y1="56" x2="752" y2="56" stroke="#666"/><rect x="760" y="16" width="152" height="40" rx="4" fill="#000"/>${text(836,41,"Add comment",14,"#fff",'font-weight="600" text-anchor="middle"')}${text(16,88,"○ Send to channel · Preview required",12,"#000")}</g>${annotations([{x:272,y:168,w:944,h:104},{x:272,y:288,w:656,h:144},{x:272,y:440,w:656,h:160},{x:272,y:616,w:944,h:120},{x:936,y:288,w:280,h:312}])}</svg>`;
}
function desktopLink(screen) {
const cards = screen.panels.map((p,i)=>desktopCard(280+i*312,248,288,248,p,i)).join("\n");
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="1280" height="800"/>${text(48,48,"Paperclip",20,"#000",'font-weight="600"')}<circle cx="1232" cy="40" r="16" fill="#e6e6e6"/>${text(640,144,screen.title,28,"#000",'font-weight="700" text-anchor="middle"')}${text(640,176,screen.subtitle,14,"#666",'text-anchor="middle"')}${cards}<g transform="translate(488,544)"><rect width="304" height="48" rx="4" fill="#000"/>${text(152,30,"Confirm identity link",14,"#fff",'font-weight="600" text-anchor="middle"')}</g>${text(640,624,"Single use · Expires in 9 minutes · Revoke from endpoint Access",12,"#666",'text-anchor="middle"')}${annotations([{x:272,y:104,w:936,h:88},{x:272,y:240,w:304,h:264},{x:584,y:240,w:304,h:264},{x:480,y:536,w:320,h:64},{x:376,y:600,w:528,h:40}])}</svg>`;
}
function desktopMatrix(screen) {
const rows = [
["Workspace app","Slack · Teams · Discord","Yes","Native/edit","Rich"],
["Comment system","GitHub · Linear · Notion","Yes","Edit","Link/card"],
["Bot token","Telegram","Yes","Draft/edit","Keyboard"],
["Meta messaging","WhatsApp · Messenger","DM","Post","Buttons"],
["Phone/iMessage","Twilio · Photon · Linq","DM","Post","Limited"],
["Social/email","X · Resend","Mixed","Post/edit","Mixed"],
];
const body = rows.map((r,i)=>`<g transform="translate(280,${272+i*56})"><rect width="928" height="56" ${i%2?'fill="#e6e6e6"':''}/>${text(16,34,r[0],14,"#000",'font-weight="600"')}${text(200,34,r[1],14,"#666")}${text(520,34,r[2],14,"#666")}${text(640,34,r[3],14,"#666")}${text(792,34,r[4],14,"#666")}</g>`).join("\n");
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="1280" height="800"/>${desktopSidebar(screen)}${text(280,128,screen.title,28,"#000",'font-weight="700"')}${text(280,160,screen.subtitle,14,"#666")}<g transform="translate(280,208)"><rect width="928" height="56" fill="#e6e6e6"/>${text(16,34,"Setup pattern",12,"#000",'font-weight="600"')}${text(200,34,"Providers",12,"#000",'font-weight="600"')}${text(520,34,"Mentions",12,"#000",'font-weight="600"')}${text(640,34,"Streaming",12,"#000",'font-weight="600"')}${text(792,34,"Interactions",12,"#000",'font-weight="600"')}</g>${body}<g transform="translate(280,632)"><rect width="928" height="88" rx="8"/>${text(24,32,"Shared operational states",14,"#000",'font-weight="600"')}${text(24,64,"Loading · Empty · Degraded · Denied · Unsupported fallback · Rate limited · Revoked · Dead letter",12,"#666")}</g>${annotations([{x:272,y:264,w:192,h:352},{x:784,y:200,w:432,h:416},{x:464,y:200,w:320,h:416},{x:272,y:200,w:192,h:64},{x:272,y:624,w:944,h:104}])}</svg>`;
}
function mobileHeader(screen) {
return `<rect x="0" y="0" width="375" height="64"/><text x="16" y="40" font-size="14" font-weight="600" stroke="none" fill="#000">${esc(screen.context)}</text><text x="343" y="40" font-size="14" text-anchor="end" stroke="none" fill="#666">Menu</text>`;
}
function mobileCard(y, panel, index, compact = false) {
const [heading, ...lines] = panel;
const height = compact ? 120 : 144;
return `<g transform="translate(16,${y})" data-region="panel-${index + 1}"><rect width="343" height="${height}" rx="8" ${index===0?'fill="#e6e6e6"':''}/>${text(16,32,heading,14,"#000",'font-weight="600"')}${lines.slice(0,3).map((line,i)=>text(16,64+i*24,line,12,i===2?"#000":"#666",i===2?'font-weight="600"':"")).join("\n")}</g>`;
}
function mobileGeneric(screen) {
const start = screen.step ? 184 : 160;
const compact = screen.panels.length > 3;
const gap = compact ? 128 : 152;
const cards = screen.panels.slice(0,4).map((p,i)=>mobileCard(start+i*gap,p,i,compact)).join("\n");
const lastY = start + Math.min(screen.panels.length,4)*gap;
return `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="375" height="812"/>${mobileHeader(screen)}${screen.step?text(16,88,screen.step,12,"#666",'font-weight="600"'):""}${text(16,screen.step?120:104,screen.title,20,"#000",'font-weight="600"')}${mobileSubtitle(screen.step?144:128,screen.subtitle)}${cards}<g transform="translate(16,${Math.min(lastY,744)})"><rect width="343" height="48" rx="4" fill="#000"/>${text(171,30,screen.kind==="wizard"?"Continue":screen.id==="15"?"Add channel":"Save",14,"#fff",'font-weight="600" text-anchor="middle"')}</g>${annotations([{x:8,y:72,w:359,h:88},{x:8,y:start-8,w:359,h:160},{x:8,y:start+gap-8,w:359,h:160},{x:8,y:start+gap*2-8,w:359,h:160},{x:8,y:Math.min(lastY-8,736),w:359,h:64}],true)}</svg>`;
}
function mobileCatalog(screen) {
const cards=screen.panels.map((panel,index)=>`<g transform="translate(16,${232+index*104})" data-region="provider-${index+1}"><rect width="343" height="96" rx="8" ${index===0?'fill="#e6e6e6"':''}/>${text(16,24,panel[0],14,"#000",'font-weight="600"')}${text(16,48,panel[1],12,"#666")}${text(16,68,panel[2],12,"#666")}${text(327,88,panel[3],12,"#000",'font-weight="600" text-anchor="end"')}</g>`).join("\n");
return `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="375" height="812"/>${mobileHeader(screen)}${text(16,104,screen.title,20,"#000",'font-weight="600"')}${mobileSubtitle(128,screen.subtitle)}<g transform="translate(16,152)"><rect width="343" height="48" rx="24"/><circle cx="24" cy="24" r="8"/><line x1="32" y1="32" x2="40" y2="40"/>${text(48,30,"Search connectors",14,"#666")}</g>${text(16,216,"All Tools Channels Connected",12,"#000",'font-weight="600"')}${cards}${annotations([{x:8,y:0,w:359,h:64},{x:8,y:144,w:359,h:88},{x:8,y:224,w:359,h:528},{x:24,y:488,w:184,h:32},{x:240,y:224,w:128,h:528}],true)}</svg>`;
}
function mobileTask(screen) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="375" height="812"/>${mobileHeader(screen)}${text(16,96,"PAP-1842",12,"#666")}${text(16,128,screen.title,20,"#000",'font-weight="600"')}<g transform="translate(16,152)"><rect width="343" height="104" rx="8" fill="#e6e6e6"/>${text(16,32,"Slack · #customer-support",14,"#000",'font-weight="600"')}${text(16,56,"Assigned agent locked to Maya",12,"#666")}${text(16,80,"Open Slack · Manage connection",12,"#000",'font-weight="600"')}</g><g transform="translate(16,280)"><rect width="343" height="120" rx="8"/><circle cx="32" cy="32" r="16" fill="#e6e6e6"/>${text(56,32,"Ari S. · External participant",12,"#000",'font-weight="600"')}${text(16,72,"The refund step is timing out again.",14,"#000")}${text(16,96,"Linked as Ari Stone · 8m ago",12,"#666")}</g><g transform="translate(16,424)"><rect width="343" height="136" rx="8" fill="#e6e6e6"/>${text(16,32,"Maya · Agent output",12,"#000",'font-weight="600"')}${text(16,64,"I found the failing retry boundary…",14,"#000")}${text(16,96,"Delivered to Slack",12,"#666")}${text(16,120,"retry-analysis.md",12,"#000",'font-weight="600"')}</g><g transform="translate(16,584)"><rect width="343" height="136" rx="8"/>${text(16,32,"Internal note",12,"#666")}<line x1="16" y1="56" x2="327" y2="56" stroke="#666"/>${text(16,88,"○ Send to channel · Preview",12,"#000")}<rect x="207" y="80" width="120" height="40" rx="4" fill="#000"/>${text(267,105,"Comment",14,"#fff",'font-weight="600" text-anchor="middle"')}</g>${annotations([{x:8,y:144,w:359,h:120},{x:8,y:272,w:359,h:136},{x:8,y:416,w:359,h:152},{x:8,y:576,w:359,h:152},{x:200,y:648,w:152,h:64}],true)}</svg>`;
}
function mobileLink(screen) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="375" height="812"/>${text(16,40,"Paperclip",14,"#000",'font-weight="600"')}${text(16,96,screen.title,20,"#000",'font-weight="600"')}${mobileSubtitle(120,screen.subtitle)}${screen.panels.map((p,i)=>mobileCard(152+i*152,p,i)).join("\n")}<g transform="translate(16,624)"><rect width="343" height="48" rx="4" fill="#000"/>${text(171,30,"Confirm identity link",14,"#fff",'font-weight="600" text-anchor="middle"')}</g>${text(187,704,"Single use · Expires in 9 minutes",12,"#666",'text-anchor="middle"')}${annotations([{x:8,y:72,w:359,h:56},{x:8,y:144,w:359,h:160},{x:8,y:296,w:359,h:160},{x:8,y:616,w:359,h:64},{x:8,y:688,w:359,h:40}],true)}</svg>`;
}
function mobileMatrix(screen) {
const rows=[["Workspace apps","Slack · Teams · Discord"],["Comment systems","GitHub · Linear · Notion"],["Bot token","Telegram"],["Meta messaging","WhatsApp · Instagram"],["Phone/iMessage","Twilio · Photon · Linq"],["Social/email","X · Resend"]];
return `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="375" height="812"/>${mobileHeader(screen)}${text(16,104,screen.title,20,"#000",'font-weight="600"')}${mobileSubtitle(128,screen.subtitle)}${rows.map((r,i)=>`<g transform="translate(16,${160+i*72})"><rect width="343" height="64" rx="4" ${i%2?'fill="#e6e6e6"':''}/>${text(16,26,r[0],14,"#000",'font-weight="600"')}${text(16,50,r[1],12,"#666")}${text(327,38,"",20,"#000",'text-anchor="end"')}</g>`).join("\n")}<g transform="translate(16,616)"><rect width="343" height="104" rx="8"/>${text(16,32,"Shared states",14,"#000",'font-weight="600"')}${text(16,56,"Loading · Empty · Degraded · Denied",12,"#666")}${text(16,80,"Rate limited · Revoked · Dead letter",12,"#666")}</g>${annotations([{x:8,y:152,w:176,h:448},{x:184,y:152,w:183,h:448},{x:8,y:152,w:359,h:232},{x:8,y:384,w:359,h:216},{x:8,y:608,w:359,h:120}],true)}</svg>`;
}
function flowSvg() {
const cells = screens.map((s, i) => {
const col = i % 5;
const row = Math.floor(i / 5);
const x = 48 + col * 240;
const y = 96 + row * 168;
return `<g transform="translate(${x},${y})"><rect width="192" height="112" rx="8" ${[8,9,14,15].includes(i)?'fill="#e6e6e6"':''}/><rect x="16" y="16" width="40" height="40" rx="4" fill="#e6e6e6"/>${text(72,32,s.id,12,"#666",'font-weight="600"')}${text(72,56,s.title.length>14?`${s.title.slice(0,14)}`:s.title,14,"#000",'font-weight="600"')}${text(16,88,i<9?"SETUP":i<14?"MANAGE":"RELATED",12,"#666",'font-weight="600"')}</g>`;
}).join("\n");
const arrows=[];
for(let i=0;i<screens.length-1;i++){
const c=i%5,r=Math.floor(i/5); const nc=(i+1)%5,nr=Math.floor((i+1)/5);
if(r===nr){const x=240+c*240,y=152+r*168;arrows.push(`<line x1="${x}" y1="${y}" x2="${x+32}" y2="${y}"/><polygon points="${x+32},${y} ${x+24},${y-8} ${x+24},${y+8}" fill="#000" stroke="none"/>`);}
}
arrows.push(`<path d="M 1008 208 C 1104 232, 1104 248, 48 264" fill="none" stroke="#000" stroke-dasharray="6 3"/>`);
arrows.push(`<path d="M 768 544 C 768 640, 1008 640, 1008 600" fill="none" stroke="#000" stroke-dasharray="6 3"/>`);
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5"><rect width="1280" height="800"/>${text(48,40,"Chat adapters · Paperclip product flow",28,"#000",'font-weight="700"')}${text(48,72,"Solid arrows follow the primary review path; dashed arrows mark management, identity, relay, diagnostics, and detach branches.",14,"#666")}${cells}${arrows}${annotations([{x:40,y:88,w:1168,h:312},{x:40,y:416,w:1168,h:312}])}</svg>`;
}
function viewerHtml() {
const templatePath = join(root, "../../../packages/skills-catalog/catalog/bundled/product/wireframe/assets/site-template.html");
const template = readFileSync(templatePath, "utf8");
const style = template.match(/<style>[\s\S]*?<\/style>/)?.[0];
if (!style) throw new Error(`Could not read viewer styles from ${templatePath}`);
const toc = screens.map((screen) =>
`<a href="#s${screen.id}"><span class="num">${Number(screen.id)}</span>${esc(screen.title)}</a>`,
).join("\n");
const sections = screens.map((screen) => {
const notes = screen.annotations.map((note, index) =>
`<li><b>${index + 1}</b> — ${esc(note).replaceAll("**", "")}</li>`,
).join("\n");
const rationale = screen.notes.map((note) => esc(note)).join(" ");
return `<section id="s${screen.id}">
<div class="lede">${Number(screen.id) <= 9 ? "Setup" : Number(screen.id) <= 14 ? "Endpoint management" : "Related surfaces"}</div>
<h2><span class="step-num">${Number(screen.id)}.</span>${esc(screen.title)}</h2>
<p class="desc">${esc(screen.subtitle)}</p>
<div class="grid">
<div class="wire" data-zoom data-caption="${screen.id} · ${esc(screen.title)} (desktop)">
<div class="label"><span>${screen.id}-${screen.slug}.svg</span><span>1280×800 · desktop</span></div>
<img src="wireframes/${screen.id}-${screen.slug}.svg" alt="${esc(screen.title)} desktop wireframe" />
</div>
<div class="wire mobile-wire mobile-col" data-zoom data-caption="${screen.id} · ${esc(screen.title)} (mobile)">
<div class="label"><span>mobile</span><span>375×812</span></div>
<img src="wireframes/${screen.id}-${screen.slug}-mobile.svg" alt="${esc(screen.title)} mobile wireframe" />
</div>
<div class="notes-col">
<div class="notes">
<h3>Annotations</h3>
<ul>${notes}</ul>
<div class="why"><b>Rationale:</b> ${rationale}</div>
</div>
</div>
</div>
</section>`;
}).join("\n");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Paperclip chat adapters planning review</title>
${style}
<style>
.doc-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 16px; }
.doc-links a { min-height: 48px; display: inline-flex; align-items: center; font-size: 13px; font-weight: 600; }
.notice { max-width: var(--maxw); margin: -32px 0 48px; padding: 14px 18px; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 4px; }
.notice p { margin: 0; }
code { font-size: 0.92em; }
</style>
</head>
<body>
<div class="shell">
<details class="toc">
<summary class="toc-summary">
<span><span class="crumb">Chat adapters · planning</span><br><span class="title">Jump to a screen</span></span>
<span class="chevron" aria-hidden="true"></span>
</summary>
<nav class="toc-body" aria-label="Section navigation">
<h1>Chat adapters</h1>
<div style="font-size: 13px; color: var(--muted); margin-bottom: 16px;">Planning review package</div>
<h2>Documents</h2>
<a href="2026-09-03-chat-adapters-architecture.md"><span class="num">A</span>Architecture</a>
<a href="2026-09-03-chat-adapters-research-notes.md"><span class="num">R</span>Research notes</a>
<a href="2026-09-03-chat-adapters-ui-surfaces.md"><span class="num">U</span>UI specification</a>
<h2>Flow</h2>
<a href="#flow"><span class="num"></span>Product flow</a>
<h2>Screens</h2>
${toc}
<h2>Review</h2>
<a href="#coverage"><span class="num"></span>Coverage and sources</a>
</nav>
</details>
<main>
<header class="hero">
<div class="crumb">Paperclip · Chat adapters · Planning artifact</div>
<h1>Connect one Paperclip agent to every place people already work</h1>
<p>This package defines the administration, agent, task, identity-link, and relay surfaces for durable external chat endpoints. Paperclip remains the control plane; provider channels are communication media.</p>
<div class="doc-links">
<a href="2026-09-03-chat-adapters-architecture.md">Read architecture plan</a>
<a href="2026-09-03-chat-adapters-research-notes.md">Read research appendix</a>
<a href="2026-09-03-chat-adapters-ui-surfaces.md">Read UI surface specification</a>
</div>
<div class="pills">
<span class="pill">19 product screens</span>
<span class="pill">Desktop + mobile</span>
<span class="pill">Slack-first · 5-provider launch</span>
<span class="pill">Click any wireframe to zoom</span>
</div>
</header>
<div class="notice" role="note"><p><b>Review convention:</b> red dashed marks and numbered circles are annotations only. They are not proposed Paperclip interface elements.</p></div>
<section id="flow" class="flow-section">
<div class="lede">Navigation and product flow</div>
<h2>From Apps discovery to an externally bound task</h2>
<p class="desc">Solid arrows follow setup and activation. Dashed paths branch to endpoint management, identity linking, private-instance relay, diagnostics, and detach/rebind. This is a product navigation flow, not a system architecture diagram.</p>
<div class="wire" data-zoom data-caption="Chat adapters product flow">
<div class="label"><span>flow.svg</span><span>1280×800</span></div>
<img src="wireframes/flow.svg" alt="Chat adapters navigation and product flow" />
</div>
<div class="notes"><h3>Annotations</h3><ul><li><b>1</b> Discovery, connection-method choice, setup, review, and activation.</li><li><b>2</b> Endpoint operations and the agent, task, identity-link, relay, and adapter-state branches.</li></ul></div>
</section>
${sections}
<section id="coverage">
<div class="lede">Coverage and sources</div>
<h2>Review checklist</h2>
<div class="notes">
<ul>
<li><b>Paperclip invariant:</b> agents, tasks, runs, permissions, approvals, budgets, artifacts, and audit history remain authoritative in Paperclip.</li>
<li><b>Provider model:</b> one installed native bot identity maps to exactly one Paperclip agent endpoint.</li>
<li><b>First supported set:</b> Slack, Microsoft Teams, Discord, Telegram, and GitHub.</li>
<li><b>Thread model:</b> a root mention creates/opens a provider thread and one endpoint-owned Paperclip issue where supported; GitHub binds an existing issue/PR/discussion thread; Telegram uses the stable chat/topic boundary.</li>
<li><b>Chat SDK coverage:</b> events, streaming, cards, actions, modals, commands, emoji, files, DMs, ephemeral output, and overlap policies appear in screens 07, 12, 14, and 19.</li>
<li><b>Research pins:</b> Paperclip <code>b84964e5a2fa8b1e6498a1ccb471f6adba97d470</code>; Vercel Chat SDK <code>51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c</code>; OpenTag <code>6a770d862349f8e996c23c145aef6d6275914a23</code>.</li>
<li><b>Current-state screenshots:</b> omitted because no deterministic local fixture was used; no reference UI has been invented.</li>
</ul>
</div>
</section>
<div class="footer">Generated from Paperclip's bundled <code>wireframe</code> skill viewer template. Wires use black 1.5 strokes, white surfaces, grayscale placeholders, an 8px rhythm, and 12/14/20/28 type sizes. Red is reserved for review annotations.</div>
</main>
</div>
<div class="lightbox" id="lb" aria-hidden="true">
<span class="close" id="lbClose" role="button" aria-label="Close preview">×</span>
<img id="lbImg" alt="" />
<div class="caption" id="lbCap"></div>
</div>
<script>
const lb = document.getElementById('lb');
const lbImg = document.getElementById('lbImg');
const lbCap = document.getElementById('lbCap');
document.querySelectorAll('[data-zoom]').forEach((el) => {
el.addEventListener('click', () => {
const target = el.tagName === 'IMG' ? el : el.querySelector('img');
if (!target) return;
lbImg.src = target.src;
lbImg.alt = target.alt;
lbCap.textContent = el.dataset.caption || target.alt || '';
lb.classList.add('open');
lb.setAttribute('aria-hidden', 'false');
});
});
function closeLightbox() { lb.classList.remove('open'); lb.setAttribute('aria-hidden', 'true'); }
lb.addEventListener('click', closeLightbox);
document.getElementById('lbClose').addEventListener('click', closeLightbox);
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeLightbox(); });
const tocElement = document.querySelector('details.toc');
const media = window.matchMedia('(max-width: 900px)');
const setToc = () => { tocElement.open = !media.matches; };
setToc();
media.addEventListener('change', setToc);
tocElement.querySelectorAll('.toc-body a').forEach((link) => link.addEventListener('click', () => { if (media.matches) tocElement.open = false; }));
</script>
</body>
</html>`;
}
for (const screen of screens) {
const desktop = screen.kind === "catalog" ? desktopCatalog(screen)
: screen.kind === "task" ? desktopTask(screen)
: screen.kind === "link" ? desktopLink(screen)
: screen.kind === "matrix" ? desktopMatrix(screen)
: desktopGeneric(screen);
const mobile = screen.kind === "catalog" ? mobileCatalog(screen)
: screen.kind === "task" ? mobileTask(screen)
: screen.kind === "link" ? mobileLink(screen)
: screen.kind === "matrix" ? mobileMatrix(screen)
: mobileGeneric(screen);
writeFileSync(join(out, `${screen.id}-${screen.slug}.svg`), `${desktop}\n`);
writeFileSync(join(out, `${screen.id}-${screen.slug}-mobile.svg`), `${mobile}\n`);
}
writeFileSync(join(out, "flow.svg"), `${flowSvg()}\n`);
writeFileSync(join(root, "index.html"), `${viewerHtml()}\n`);
console.log(`Generated ${screens.length * 2 + 1} SVGs and index.html in ${root}`);

View File

@ -0,0 +1,164 @@
export const permissionModel = [
[
"Provider availability",
"Slack, Teams, and Telegram decide where the bot is installed or invited. GitHub decides which repositories belong to the App installation."
],
[
"Paperclip enablement",
"Paperclip responds only in provider resources that a Paperclip administrator has enabled for this connection. Invitation or installation alone is not permission to create a task."
],
[
"Effective reach",
"A message is eligible only when the provider delivers it, its resource is enabled in Paperclip, the connection is active, and the sender has authority for the requested action."
],
[
"Safe default",
"The destination used for the successful setup test becomes the first enabled resource. Resources discovered later start disabled."
]
];
export const providerManagement = {
Slack: {
id: "14",
slug: "slack-settings",
short: "Slack",
providerAction: "Add Maya to another Slack channel ↗",
providerActionHelp: "Opens Slack instructions. After Maya is invited, the channel appears here disabled.",
settingsTitle: "Slack settings",
settingsSubtitle: "Enable the Slack channels where Maya may create and continue tasks.",
resourcesTitle: "Channels",
resourcesIntro: "Only channels where Maya is already a member can be enabled.",
resources: [
["#customer-support · Acme", "Invited in Slack · Enabled", true],
["#incidents · Acme", "Invited in Slack · Enabled", true],
["#product · Acme", "Invited in Slack · Not enabled", false]
],
conversationToggles: [
["Allow direct messages", "People may start private tasks by messaging Maya.", true]
],
accessTitle: "Slack access",
accessSubtitle: "Decide how people are identified when they message Maya.",
unlinkedLabel: "Allow unlinked people",
unlinkedDetail: "In enabled channels, unlinked Slack members can start and continue tasks with restricted access.",
identityHint: "Slack workspace ID + user ID",
linked: [
["Ari Chen · U0184", "ari@acme.com · Member", "Revoke"],
["Sam Rivera · U0191", "sam@acme.com · Viewer", "Revoke"]
],
conversationsTitle: "Slack conversations",
conversationsSubtitle: "Conversations created through this connection.",
openProvider: "Open Slack",
conversations: [
["#customer-support · Refund timeout", "PAP-1842 · Refund workflow is failing", "Working · 18s"],
["#incidents · Queue delay", "PAP-1838 · Investigate queue delay", "Waiting · 12m"],
["Direct message · Ari Chen", "PAP-1831 · Customer export", "Completed · 2h"]
]
},
GitHub: {
id: "17",
slug: "github-settings",
short: "GitHub",
providerAction: "Manage GitHub installation ↗",
providerActionHelp: "Opens GitHub. Repositories added to the App installation appear here disabled.",
settingsTitle: "GitHub settings",
settingsSubtitle: "Enable the repositories where Maya may respond to mentions.",
resourcesTitle: "Repositories",
resourcesIntro: "Only repositories selected in the GitHub App installation can be enabled.",
resources: [
["acme/api", "Available in GitHub installation · Enabled", true],
["acme/web", "Available in GitHub installation · Enabled", true],
["acme/docs", "Available in GitHub installation · Not enabled", false]
],
conversationToggles: [],
accessTitle: "GitHub access",
accessSubtitle: "Decide how people are identified when they mention Maya.",
unlinkedLabel: "Allow unlinked people",
unlinkedDetail: "In enabled repositories, unlinked GitHub users can start and continue tasks with restricted access.",
identityHint: "GitHub host + numeric user ID",
linked: [
["arichen · 481902", "ari@acme.com · Member", "Revoke"],
["sam-rivera · 592113", "sam@acme.com · Viewer", "Revoke"]
],
conversationsTitle: "GitHub conversations",
conversationsSubtitle: "Conversations created through this connection.",
openProvider: "Open GitHub",
conversations: [
["acme/api · Issue #482", "PAP-1850 · Retry API timeouts", "Working · 3m"],
["acme/web · Pull request #912", "PAP-1846 · Review checkout change", "Waiting · 22m"],
["acme/api · Review thread", "PAP-1829 · Fix response typing", "Completed · 1d"]
]
},
"Microsoft Teams": {
id: "20",
slug: "teams-settings",
short: "Teams",
providerAction: "Add Maya to another team ↗",
providerActionHelp: "Opens Teams instructions. Channels in the newly installed team appear here disabled.",
settingsTitle: "Microsoft Teams settings",
settingsSubtitle: "Enable the Teams channels where Maya may create and continue tasks.",
resourcesTitle: "Channels",
resourcesIntro: "Only channels in teams where Maya is installed can be enabled.",
resources: [
["Support / General · Acme", "Installed in Teams · Enabled", true],
["Engineering / Incidents · Acme", "Installed in Teams · Enabled", true],
["Product / General · Acme", "Installed in Teams · Not enabled", false]
],
conversationToggles: [
["Allow direct messages", "People may start tasks in personal chats with Maya.", true],
["Allow group chats", "People may add Maya to a group chat and start tasks there.", false]
],
accessTitle: "Microsoft Teams access",
accessSubtitle: "Decide how people are identified when they message Maya.",
unlinkedLabel: "Allow unlinked people",
unlinkedDetail: "In enabled Teams conversations, unlinked members can start and continue tasks with restricted access.",
identityHint: "Microsoft tenant ID + Entra object ID",
linked: [
["Ari Chen · 62af…91c", "ari@acme.com · Member", "Revoke"],
["Sam Rivera · 74bd…10a", "sam@acme.com · Viewer", "Revoke"]
],
conversationsTitle: "Microsoft Teams conversations",
conversationsSubtitle: "Conversations created through this connection.",
openProvider: "Open Teams",
conversations: [
["Support / General · Refund timeout", "PAP-1861 · Fix refund timeout", "Working · 42s"],
["Engineering / Incidents · Queue delay", "PAP-1857 · Diagnose queue delay", "Waiting · 8m"],
["Personal chat · Ari Chen", "PAP-1841 · Export account history", "Completed · 4h"]
]
},
Telegram: {
id: "23",
slug: "telegram-settings",
short: "Telegram",
providerAction: "Add Maya to another Telegram chat ↗",
providerActionHelp: "Opens instructions. After Maya receives a message there, the chat appears here disabled.",
settingsTitle: "Telegram settings",
settingsSubtitle: "Enable the Telegram chats and topics where Maya may create and continue tasks.",
resourcesTitle: "Chats and topics",
resourcesIntro: "Only chats where the bot is present and discovered can be enabled.",
resources: [
["Operations group", "Bot is present · Enabled", true],
["Support forum / Refunds", "Bot is present · Enabled", true],
["Product group", "Bot is present · Not enabled", false]
],
conversationToggles: [
["Allow direct messages", "People may start private tasks by messaging Maya.", true]
],
accessTitle: "Telegram access",
accessSubtitle: "Decide how people are identified when they message Maya.",
unlinkedLabel: "Allow unlinked people",
unlinkedDetail: "In enabled chats, unlinked Telegram users can start and continue tasks with restricted access.",
identityHint: "Telegram bot ID + numeric user ID",
linked: [
["Ari Chen · 18409211", "ari@acme.com · Member", "Revoke"],
["Sam Rivera · 18410482", "sam@acme.com · Viewer", "Revoke"]
],
conversationsTitle: "Telegram conversations",
conversationsSubtitle: "Conversations created through this connection.",
openProvider: "Open Telegram",
conversations: [
["Operations group · Deployment alert", "PAP-1870 · Check deployment alert", "Working · 25s"],
["Support forum / Refunds", "PAP-1866 · Trace missing refund", "Waiting · 6m"],
["Private chat · Ari Chen", "PAP-1852 · Prepare customer export", "Completed · 3h"]
]
}
};

View File

@ -0,0 +1,57 @@
import { providerScreens as v2Screens } from "./platform-wireframe-data.mjs";
const interactionAnnotations = {
"15": [
"Ari starts in a Slack channel with a root @maya mention; unrelated root messages do not start work.",
"Maya acknowledges inside a Slack thread, making the thread—not the channel—the visible conversation boundary.",
"Paperclip creates exactly one assigned issue and shows its Slack source, external participant, and publication state.",
"Ari continues by replying in the same thread without another mention; files and actions remain in that context.",
"Maya's safe progress and final answer publish in the thread; failures offer retry or a Paperclip link."
],
"18": [
"Ari mentions the bot in an existing GitHub issue, PR conversation, or inline review thread.",
"Maya acknowledges with a reaction and one GitHub-Flavored Markdown comment rather than opening another thread.",
"Paperclip binds that exact GitHub object or review thread to one assigned issue; PR conversation and inline review stay distinct.",
"Later comments continue the same issue, while bot-authored comments and duplicate deliveries are ignored.",
"Progress edits the existing comment; files and governed actions use authenticated Paperclip links."
],
"21": [
"Ari mentions Maya in a new Teams channel post; that post and its replies are the native thread.",
"Maya acknowledges under the post. If the installed permissions cannot deliver unmentioned replies, the bot says to mention Maya again.",
"Paperclip creates one assigned issue and records tenant, team/channel, thread, and external participant attribution.",
"Replies, files, and Adaptive Card or task-module actions continue only when current Teams delivery and Paperclip permissions allow.",
"DMs may stream natively; channel and group output buffers or edits, with targeted-message, DM, or text-link fallback."
],
"24": [
"In a DM, Ari's first message creates the active issue; New task or /new deliberately starts another.",
"In a privacy-on group, @maya starts work and replying to Maya continues; unrelated group traffic is not consumed.",
"A forum topic can bind one issue through message_thread_id when the bot is present and allowed.",
"Paperclip shows the active issue and makes the linear-chat boundary explicit instead of implying a Slack-style native thread.",
"Maya uses throttled post/edit and inline buttons; unsupported or governed actions return text or DM with a Paperclip link."
]
};
const interactionTitles = {
"15": "How Slack conversations work",
"18": "How GitHub conversations work",
"21": "How Microsoft Teams conversations work",
"24": "How Telegram conversations work"
};
const interactionSubtitles = {
"15": "What Ari sees in Slack and the single Paperclip issue created behind the thread.",
"18": "What Ari sees in GitHub and how the existing object becomes one Paperclip issue.",
"21": "What Ari sees in a channel thread, with separate DM and group-chat behavior.",
"24": "How DMs, privacy-on groups, and forum topics establish an explicit active issue."
};
export const providerScreens = v2Screens.map((screen) => {
if (screen.kind !== "providerInteractions") return { ...screen };
return {
...screen,
title: interactionTitles[screen.id],
subtitle: interactionSubtitles[screen.id],
annotations: interactionAnnotations[screen.id],
rationale: "This is a product-behavior walkthrough: the external conversation people see beside the Paperclip issue it creates."
};
});

View File

@ -0,0 +1,504 @@
export const baseSha = "d593463ab6394cd356bf27448ea28bad8cccf4ec";
const sharedAnnotations = {
overview: [
"The endpoint keeps one Paperclip agent and one provider-native bot identity together.",
"Installation and delivery health are summarized before any configuration detail.",
"Every safe capability available to this provider is included automatically; this is status, not a set of switches.",
"Test, pause, reconnect, and remove remain ordinary connector lifecycle actions."
],
access: [
"The endpoint sponsor supplies the maximum authority available to unlinked external people.",
"Linked provider identities act as their current Paperclip users and retain ordinary permission checks.",
"Unlinked people use the restricted sponsored-guest profile and cannot perform governance actions.",
"Provider identity and scope details make effective authority explainable and auditable."
],
conversations: [
"Each row names the provider-native conversation boundary and its single Paperclip issue.",
"Participants, assigned agent, state, and last activity make live bindings scannable.",
"Open in provider and Open task take an operator to either side of the binding.",
"Detach preserves history and publication records; a later activation creates or claims a new binding."
],
activity: [
"Provider, credential, callback, and deployment-selected delivery health are summarized in one operational section.",
"Inbound deliveries, callbacks, and outbound publications share a durable chronological ledger.",
"Operators can inspect redacted errors and replay only safe, authorized failed deliveries.",
"Rate limits, permission drift, uninstall or revocation, and provider-specific diagnostics stay visible."
]
};
export const providers = [
{
name: "Slack", short: "Slack", slug: "slack", ids: { setup: "13", settings: "14", walkthrough: "15", overview: "25", access: "26", conversations: "27", activity: "28" },
setupTitle: "Invite Maya to Slack",
setupSubtitle: "Create or select one Slack app, install it, and verify the workspace connection.",
setupSections: [
{ title: "Agent and Slack identity", intro: "This endpoint represents exactly one Paperclip agent.", rows: [
["Paperclip agent", "Maya · Support engineer", "Change agent"],
["Slack bot", "Maya · @maya · avatar preview", "Preview"]
]},
{ title: "Choose delivery", intro: "Paperclip generates the callback address before the Slack app is created.", rows: [
["Recommended", "Direct signed webhook for cloud or public self-hosted Paperclip.", "Direct webhook"],
["Private Paperclip", "Use the outbound authenticated relay when this instance is not publicly reachable.", "Use relay"],
["Slack alternative", "Socket Mode uses an app token and one persistent listener.", "Advanced"]
]},
{ title: "Create and install the Slack app", intro: "The generated manifest contains the exact URLs, events, scopes, interactivity, and command declarations.", rows: [
["1. Copy app manifest", "Create a new Slack app from this versioned manifest.", "Copy manifest"],
["2. Install to workspace", "Slack owns workspace approval, OAuth, and Enterprise Grid policy.", "Open Slack"],
["3. Invite Maya", "Add @maya to each channel where people should be able to start work.", "Instructions"]
]},
{ title: "Connect credentials", intro: "Paperclip stores secret references, never raw values in endpoint configuration.", rows: [
["Bot or OAuth token", "Secret ref · slack/maya-bot ·•••• 8F2A", "Replace"],
["Signing secret", "Secret ref · slack/maya-signing ·•••• 0C91", "Replace"],
["App token", "Required only when Socket Mode is selected.", "Not set"]
]},
{ title: "Verify and activate", intro: "Each check fails independently so the operator knows where to fix the Slack app.", rows: [
["Identity and install", "Bot @maya · Acme workspace · membership detected", "Passed"],
["Security and events", "Signature challenge · scopes · subscriptions · interactivity", "Passed"],
["Test message", "Send a private setup check before activation.", "Send test"]
]}
],
setupAnnotations: [
"Agent and native bot identity are the first and only Paperclip binding decision.",
"Direct webhook is the default; relay and Socket Mode are explicit deployment alternatives.",
"Paperclip provides a manifest, while Slack owns app creation, approval, installation, and channel invitation.",
"Tokens and signing secrets are masked secret references with independent rotation.",
"Activation follows specific identity, signature, scope, event, interactivity, and membership checks."
],
overviewSections: [
{ title: "Connection", intro: "One bot identity represents Maya in one Slack installation.", rows: [
["Status", "Active · last event 18 seconds ago", "Healthy"],
["Agent", "Maya · Support engineer", "Open agent"],
["Slack identity", "@maya · Acme workspace", "Open Slack"]
]},
{ title: "Installation and delivery", intro: "Operational details remain visible without reopening setup.", rows: [
["Installation", "OAuth workspace · 2 invited channels", "Connected"],
["Ingress", "Verified direct webhook · p95 acknowledgement 420 ms", "Healthy"],
["Credentials", "Bot token and signing secret", "No drift"]
]},
{ title: "Available automatically", intro: "Paperclip always uses the richest safe Slack behavior permitted by this installation.", rows: [
["Conversation", "Root mention creates a native thread; subscribed replies continue the same issue.", "Included"],
["Output", "Reaction receipt, native streaming or post/edit, safe milestones, final reply.", "Included"],
["Rich interaction", "Block Kit, buttons, selects, modals, slash commands, emoji, and stop.", "Included"],
["Files and privacy", "Bounded files, DMs, ephemeral response with DM/text fallback.", "Included"]
]},
{ title: "Lifecycle", intro: "Connection actions preserve the endpoint and its audit history.", rows: [
["Connection test", "Verify identity, permissions, events, and a private response.", "Test"],
["Maintenance", "Pause delivery, reconnect OAuth, rotate secrets, or remove endpoint.", "Manage"]
]}
],
settingsSections: [
{ title: "Conversation reach", intro: "Paperclip can narrow reach but cannot exceed Slack installation and channel membership.", rows: [
["Workspace", "Acme · T02ACME", "Change install"],
["Allowed channels", "#customer-support, #incidents", "2 channels"],
["Direct messages", "People in this workspace may start a task in DM.", "Allowed"]
]},
{ title: "Task boundaries", intro: "Slack's native thread is the task boundary for channel work.", rows: [
["New channel work", "A root @maya mention creates the Slack thread and one Paperclip issue.", "Fixed"],
["Bound-thread replies", "Human replies continue without another mention.", "Subscribed"],
["Existing thread", "The first @maya mention may claim an unbound thread once.", "Allow"],
["Direct messages", "One active issue; New task starts another.", "Active task"]
]},
{ title: "Security and delivery", intro: "Paperclip reports the deployment-selected path; it is not an endpoint setting.", rows: [
["Delivery path", "Selected from instance reachability and verified continuously.", "Automatic"],
["Credential rotation", "Replace token or signing-secret references without changing bindings.", "Manage secrets"],
["Installation drift", "Pause affected resources when membership, scopes, or OAuth are revoked.", "Automatic"]
]}
],
settingsAnnotations: [
"Reach is an operator choice and is always bounded by the Slack installation and actual bot membership.",
"Root mention, native thread creation, subscribed replies, and DM task boundaries are explicit.",
"Delivery is read-only status; only credential rotation and installation repair require operator action here. Slack capabilities are reported on Overview and demonstrated in the walkthrough, never configured here."
],
accessSections: [
{ title: "Endpoint sponsor", intro: "The sponsor supplies the upper bound for restricted guests.", rows: [
["Sponsor", "Dana · Company admin", "Change sponsor"],
["Endpoint scope", "Support project · #customer-support and #incidents", "View scope"]
]},
{ title: "Linked Slack people", intro: "Linked identities act as their mapped Paperclip users.", rows: [
["Ari Chen · U0184", "ari@acme.com · Member · confirmed Sep 3", "Revoke"],
["Sam Rivera · U0191", "sam@acme.com · Viewer · confirmed Sep 4", "Revoke"],
["Link another person", "Send an expiring sign-in and company-confirmation link.", "Create link"]
]},
{ title: "Unlinked people", intro: "Unlinked Slack users are sponsored external principals, not anonymous admins.", rows: [
["Restricted guest profile", "Comment, attach files, and receive safe output inside allowed tasks.", "Default"],
["Governance", "No approvals, budget changes, hiring, permissions, connection changes, or reassignment.", "Denied"]
]},
{ title: "Slack identity rules", intro: "Workspace ID plus Slack user ID is authoritative; display names and email hints are not.", rows: [
["Bots and apps", "Ignored unless an explicit audited endpoint-to-endpoint route permits them.", "Guarded"],
["Identity audit", "Every message records external ID, link state, sponsor, and effective authority.", "Enabled"]
]}
],
conversationRows: [
["#customer-support · thread 172546.002", "PAP-1842 · Refund timeout", "Ari + 2 · Working · 18s", "Open Slack"],
["#incidents · thread 172511.119", "PAP-1838 · Queue delay", "Sam + 4 · Waiting · 12m", "Open Slack"],
["DM · D081MAYA", "PAP-1831 · Customer export", "Ari · Done · 2h", "Open Slack"]
],
conversationBoundary: "Channel root and replies share thread_ts. A stable DM conversation uses the active-task lifecycle.",
activityRows: [
["Inbound event", "Ev04K2 · message.channels · #customer-support", "Delivered · 18s"],
["Outbound publication", "Pub91A · native stream → final thread reply", "Delivered · 16s"],
["Interactive callback", "Act73C · Block Kit button · Ari", "Authorized · 4m"],
["Inbound event", "Ev04J8 · duplicate Slack retry", "Deduplicated · 9m"]
],
activityHealth: ["Slack API · healthy", "Signed webhook · healthy", "OAuth scopes · current", "Rate limit · 84% remaining"]
},
{
name: "GitHub", short: "GitHub", slug: "github", ids: { setup: "16", settings: "17", walkthrough: "18", overview: "29", access: "30", conversations: "31", activity: "32" },
setupTitle: "Connect Maya to GitHub conversations",
setupSubtitle: "Install a least-privilege GitHub App on the repositories where people will talk to Maya.",
setupSections: [
{ title: "Agent and GitHub identity", intro: "This chat endpoint is separate from any GitHub code/tool connection.", rows: [
["Paperclip agent", "Maya · Support engineer", "Change agent"],
["Purpose", "People mention Maya in issues and pull requests.", "Chat only"],
["GitHub App identity", "paperclip-maya[bot] · avatar preview", "Preview"]
]},
{ title: "Choose GitHub host and app type", intro: "A GitHub App is the production path; PAT is only for a local test.", rows: [
["Host", "GitHub.com", "Change"],
["Authentication", "GitHub App with installation-scoped credentials.", "Recommended"],
["Enterprise Server", "Add a verified API and web base URL when selected.", "Not used"]
]},
{ title: "Create the GitHub App", intro: "Paperclip supplies exact webhook and least-privilege permission values.", rows: [
["Webhook URL and secret", "Public endpoint plus generated high-entropy secret reference.", "Copy values"],
["Repository permissions", "Issues write · Pull requests write · Metadata read.", "Copy list"],
["Events", "Issue comments and pull-request review comments.", "Copy list"]
]},
{ title: "Install on repositories", intro: "GitHub owns organization approval and repository selection.", rows: [
["1. Register app", "Create the app using the values above.", "Open GitHub"],
["2. Install app", "Choose Acme and only the repositories where chat is allowed.", "Open install"],
["3. Add credentials", "App ID and private key are stored as Paperclip secret references.", "Add secrets"]
]},
{ title: "Verify and activate", intro: "Code access is intentionally absent from this connection.", rows: [
["Webhook", "Signature, delivery ID, and subscribed event verified.", "Passed"],
["Installation", "Acme org · acme/api and acme/web", "Passed"],
["Permissions", "No Contents, Actions, or Administration grant.", "Least privilege"],
["Test mention", "Create a private test issue or use an existing allowed issue.", "Send test"]
]}
],
setupAnnotations: [
"The endpoint is explicitly chat-only; repository code/tool credentials stay separate.",
"GitHub App is the production default, with host and Enterprise Server handled before registration.",
"Paperclip gives the operator exact webhook, permission, and event values in one vertical sequence.",
"GitHub owns organization approval and repository selection; Paperclip stores only secret references.",
"Verification proves delivery and installation while confirming that broad code permissions were not granted."
],
overviewSections: [
{ title: "Connection", intro: "One GitHub App bot represents Maya in the selected installation.", rows: [
["Status", "Active · last delivery 3 minutes ago", "Healthy"],
["Agent", "Maya · Support engineer", "Open agent"],
["GitHub identity", "paperclip-maya[bot] · Acme installation", "Open GitHub"]
]},
{ title: "Installation and delivery", intro: "Repository reach and webhook health stay explicit.", rows: [
["Repositories", "acme/api, acme/web", "2 selected"],
["Webhook", "Signature verified · delivery IDs deduplicated", "Healthy"],
["Permissions", "Issues and Pull requests write · Metadata read", "Current"]
]},
{ title: "Available automatically", intro: "Paperclip uses every safe interaction GitHub exposes for this chat connection.", rows: [
["Conversation", "Issue, PR conversation, and inline review-thread mentions.", "Included"],
["Output", "Reaction receipt, GFM response, coarse edit-in-place progress, final comment.", "Included"],
["Files and actions", "Ingest safe linked attachments; publish artifacts and governed actions as Paperclip links.", "Included"],
["Fallback", "Unsupported stream, DM, ephemeral, modal, or button behavior becomes text plus a link.", "Automatic"]
]},
{ title: "Lifecycle", intro: "Operate the App installation without conflating it with tool access.", rows: [
["Connection test", "Verify signature, bot identity, selected repositories, and comment response.", "Test"],
["Maintenance", "Pause, rotate private key, repair installation, or remove endpoint.", "Manage"]
]}
],
settingsSections: [
{ title: "Repository reach", intro: "Paperclip can only narrow repositories selected in the GitHub App installation.", rows: [
["Installation", "Acme organization · installation 48219", "Change install"],
["Allowed repositories", "acme/api, acme/web", "2 repositories"],
["Conversation surfaces", "Issues, PR conversations, and inline review threads.", "All supported"]
]},
{ title: "Task boundaries", intro: "GitHub's existing object or review thread is the durable conversation boundary.", rows: [
["Activation", "A direct @paperclip-maya mention binds the addressed conversation.", "Mention"],
["Pull requests", "The PR conversation and each inline review thread remain distinct.", "Separate"],
["Trusted automation", "Optional label or trusted-author activation creates work without a mention.", "Off"]
]},
{ title: "Delivery and security", intro: "These settings control the App and webhook—not individual response capabilities.", rows: [
["GitHub host", "github.com", "Change host"],
["Private key", "Secret ref · github/maya-app ·•••• A19C", "Rotate"],
["Installation drift", "Pause affected repositories after suspension or permission change.", "Automatic"]
]}
],
settingsAnnotations: [
"Repository and conversation-surface reach are the only content-scope choices.",
"Existing GitHub objects supply the issue boundary; optional non-mention activation remains an explicit workflow choice.",
"Host, private-key rotation, and installation drift are operational settings. GitHub response capabilities are reported on Overview and demonstrated in the walkthrough, never configured here."
],
accessSections: [
{ title: "Endpoint sponsor", intro: "Sponsored authority is restricted to this installation and repository allowlist.", rows: [
["Sponsor", "Dana · Company admin", "Change sponsor"],
["Endpoint scope", "Support project · acme/api and acme/web", "View scope"]
]},
{ title: "Linked GitHub people", intro: "The durable GitHub numeric user ID is linked after Paperclip authentication.", rows: [
["arichen · 184201", "ari@acme.com · Member · confirmed Sep 3", "Revoke"],
["sam-r · 194118", "sam@acme.com · Viewer · confirmed Sep 4", "Revoke"],
["Link another person", "Create an expiring company-confirmation link.", "Create link"]
]},
{ title: "Unlinked contributors", intro: "Public or outside contributors never inherit repository or company governance authority.", rows: [
["Restricted guest profile", "Comment and receive safe output only inside an allowed bound issue.", "Default"],
["Governance", "No approvals, budgets, hiring, permissions, connection management, or reassignment.", "Denied"]
]},
{ title: "GitHub identity rules", intro: "Login names may change; installation ID and numeric actor ID remain authoritative.", rows: [
["Bot comments", "Self-authored comments and duplicate webhook deliveries are suppressed.", "Ignored"],
["Code access", "This chat identity does not grant Maya repository Contents or Actions access.", "Separate"]
]}
],
conversationRows: [
["acme/api · Issue #418", "PAP-1848 · Retry regression", "Ari + 3 · Working · 3m", "Open GitHub"],
["acme/web · PR #992 conversation", "PAP-1844 · Login redirect", "Sam + 2 · Waiting · 28m", "Open GitHub"],
["acme/web · PR #992 review R881", "PAP-1843 · Cookie comment", "Ari · Done · 1h", "Open GitHub"]
],
conversationBoundary: "Issue and PR conversation objects bind once. Each inline review-comment thread has its own external key.",
activityRows: [
["Inbound delivery", "3b12a · issue_comment · acme/api#418", "Delivered · 3m"],
["Outbound publication", "Pub91B · GFM comment edit", "Delivered · 2m"],
["Inbound delivery", "3b129 · paperclip-maya[bot] self comment", "Suppressed · 8m"],
["Provider health", "Installation permission comparison", "No drift · 14m"]
],
activityHealth: ["GitHub API · healthy", "Webhook signature · healthy", "Installation · active", "Rate limit · 4,284 remaining"]
},
{
name: "Microsoft Teams", short: "Teams", slug: "teams", ids: { setup: "19", settings: "20", walkthrough: "21", overview: "33", access: "34", conversations: "35", activity: "36" },
setupTitle: "Invite Maya to Microsoft Teams",
setupSubtitle: "Register the bot, package the Teams app, install it to the intended scopes, and verify delivery.",
setupSections: [
{ title: "Agent and Teams identity", intro: "One Teams bot application represents exactly one Paperclip agent.", rows: [
["Paperclip agent", "Maya · Support engineer", "Change agent"],
["Teams bot", "Maya · app and avatar preview", "Preview"],
["Messaging endpoint", "https://chat.paperclip.app/in/••••/teams", "Copy"]
]},
{ title: "Choose Microsoft environment", intro: "Tenant and identity model must be known before app registration.", rows: [
["Cloud", "Microsoft commercial cloud", "Change"],
["Tenant mode", "Single tenant · Acme", "Change"],
["Bot authentication", "Federated workload identity", "Recommended"]
]},
{ title: "Register and package the Teams app", intro: "Teams Developer CLI is the shortest supported handoff; manual registration remains available.", rows: [
["1. Verify tenant policy", "Custom app upload or tenant-admin distribution must be allowed.", "Open policy"],
["2. Create app and bot", "Use the copied endpoint and generated manifest values.", "Copy command"],
["3. Download app package", "Paperclip validates scopes, IDs, endpoint, and package consistency.", "Download"]
]},
{ title: "Install in Teams", intro: "Microsoft owns tenant approval and the personal, team, channel, or group-chat installation.", rows: [
["Install link", "Open the Teams client installation flow.", "Open Teams"],
["Admin-managed tenant", "Export the package for the Teams administrator when sideloading is blocked.", "Export package"],
["Credentials", "Client/app ID and federated identity metadata; secret ref only if client secret is used.", "Review"]
]},
{ title: "Verify and activate", intro: "Basic mention-based chat does not require broad Graph directory or history consent.", rows: [
["Registration", "Entra app, bot ID, tenant, and messaging endpoint", "Passed"],
["Manifest and install", "Personal/team/group scopes and installed package version", "Passed"],
["Teams doctor", "Package and endpoint checks", "Passed"],
["Test message", "Send a private installation check before activation.", "Send test"]
]}
],
setupAnnotations: [
"The selected agent, Teams identity, and copyable public endpoint lead the setup.",
"Cloud, tenant mode, and exactly one bot-authentication strategy are chosen before registration.",
"Paperclip provides CLI, manifest, and package values in a conventional top-to-bottom handoff.",
"Tenant approval and installation happen in Microsoft Teams; Paperclip keeps the draft if admin action is required.",
"Verification separates registration, manifest, endpoint, installation, and doctor checks without requesting broad Graph consent."
],
overviewSections: [
{ title: "Connection", intro: "One Microsoft bot app represents Maya in the Acme tenant.", rows: [
["Status", "Active · last activity 6 minutes ago", "Healthy"],
["Agent", "Maya · Support engineer", "Open agent"],
["Teams identity", "Maya · Acme tenant", "Open Teams"]
]},
{ title: "Installation and delivery", intro: "App package, tenant, and bot endpoint are monitored independently.", rows: [
["Installation", "Personal and Support / General", "2 scopes"],
["Bot endpoint", "Authenticated activity delivery", "Healthy"],
["Identity", "Federated workload identity · single tenant", "Healthy"]
]},
{ title: "Available automatically", intro: "Paperclip uses the richest safe Teams behavior available in the current conversation scope.", rows: [
["Conversation", "Channel post threads plus explicit active-task behavior in DMs and group chats.", "Included"],
["Output", "DM-native streaming; buffered or edited channel/group responses and safe milestones.", "Included"],
["Rich interaction", "Adaptive Cards, buttons, task modules, files, reactions, and typing.", "Included"],
["Private fallback", "Targeted response when available, otherwise DM or ordinary text plus link.", "Automatic"]
]},
{ title: "Lifecycle", intro: "Package, registration, and consent health are connector operations.", rows: [
["Connection test", "Verify bot activity, package version, tenant, scopes, and response.", "Test"],
["Maintenance", "Pause, update package, repair consent, rotate identity, or remove.", "Manage"]
]}
],
settingsSections: [
{ title: "Tenant and conversation reach", intro: "Paperclip narrows the scopes where the Teams app is installed.", rows: [
["Tenant", "Acme · 0f3c••••", "Change install"],
["Teams and channels", "Support / General", "1 channel"],
["Personal scope", "Allow installed users to start work in a DM.", "Allowed"],
["Group chats", "Allow installed group chats to start work.", "Allowed"]
]},
{ title: "Task boundaries", intro: "The Teams conversation type determines the durable issue boundary.", rows: [
["Channel posts", "A root mention and the replies beneath that post map to one issue.", "Post thread"],
["DM and group chat", "One active issue until a participant chooses New task.", "Active task"],
["Unmentioned replies", "Consume when the installed manifest permits; otherwise ask for another mention.", "Detect"]
]},
{ title: "Delivery and Microsoft consent", intro: "Consent changes what Microsoft delivers; it does not switch rendering features on and off.", rows: [
["Bot identity", "Federated workload identity · single tenant", "Manage"],
["Resource-specific consent", "Optional all-message delivery for one installed team or chat.", "Not granted"],
["Graph directory/history", "Privileged admin consent remains separate from basic live chat.", "Not granted"],
["Installation drift", "Pause an affected scope after package removal or consent revocation.", "Automatic"]
]}
],
settingsAnnotations: [
"Tenant, installed team/channel, personal, and group-chat reach are explicit scope choices.",
"Channel threads and linear-conversation active tasks are different, visible issue boundaries.",
"Bot identity, RSC, Graph consent, and installation drift are the only provider-level operations. Teams capabilities are reported on Overview and demonstrated in the walkthrough, never configured here."
],
accessSections: [
{ title: "Endpoint sponsor", intro: "Sponsor authority is intersected with tenant and installed-resource scope.", rows: [
["Sponsor", "Dana · Company admin", "Change sponsor"],
["Endpoint scope", "Support project · Support / General", "View scope"]
]},
{ title: "Linked Microsoft people", intro: "Tenant ID and Entra object ID form the stable identity key.", rows: [
["Ari Chen · 31a0••••", "ari@acme.com · Member · confirmed Sep 3", "Revoke"],
["Sam Rivera · 8d11••••", "sam@acme.com · Viewer · confirmed Sep 4", "Revoke"],
["Link another person", "Create an expiring company-confirmation link.", "Create link"]
]},
{ title: "Unlinked participants", intro: "Guests, federated users, and tenant members all begin with restricted sponsored authority until linked.", rows: [
["Restricted guest profile", "Comment, attach files, and receive safe output inside an allowed issue.", "Default"],
["Governance", "No approvals, budgets, hiring, permissions, connection management, or reassignment.", "Denied"]
]},
{ title: "Microsoft identity and consent", intro: "Directory lookup may improve display metadata but never replaces the verified Teams actor key.", rows: [
["External and guest users", "Tenant context remains part of identity resolution and audit attribution.", "Guarded"],
["Bot-to-bot activity", "Ignored unless an explicit audited endpoint route permits it.", "Ignored"]
]}
],
conversationRows: [
["Support / General · post 172998", "PAP-1851 · Refund timeout", "Ari + 4 · Working · 6m", "Open Teams"],
["Group chat · 19:chat_82d", "PAP-1847 · Launch brief", "Sam + 2 · Waiting · 31m", "Open Teams"],
["Personal · Ari / Maya", "PAP-1840 · Account export", "Ari · Done · 3h", "Open Teams"]
],
conversationBoundary: "A channel root post and its replies are one issue. DM and group-chat conversations use an explicit active task.",
activityRows: [
["Inbound activity", "Act44M · message · Support / General", "Delivered · 6m"],
["Outbound publication", "Pub92T · buffered reply + Adaptive Card", "Delivered · 5m"],
["Interactive callback", "Act43Z · card action · Ari", "Authorized · 18m"],
["Permission health", "Support / General RSC comparison", "Mention-only · 1h"]
],
activityHealth: ["Bot endpoint · healthy", "App package · current", "Tenant install · active", "Graph/RSC · basic scope only"]
},
{
name: "Telegram", short: "Telegram", slug: "telegram", ids: { setup: "22", settings: "23", walkthrough: "24", overview: "37", access: "38", conversations: "39", activity: "40" },
setupTitle: "Invite Maya to Telegram",
setupSubtitle: "Create one BotFather bot, choose a delivery mode, add it to chats, and verify privacy behavior.",
setupSections: [
{ title: "Agent and Telegram identity", intro: "One Telegram username represents exactly one Paperclip agent.", rows: [
["Paperclip agent", "Maya · Support engineer", "Change agent"],
["Telegram bot", "Maya · @maya_acme_bot · avatar/about preview", "Preview"]
]},
{ title: "Create the bot with BotFather", intro: "Telegram owns username uniqueness, profile, group eligibility, and token issuance.", rows: [
["1. Create bot", "Use /newbot, then set display name and unique username.", "Open Telegram"],
["2. Configure profile", "Set avatar, about text, and group-joining policy.", "Copy values"],
["3. Keep privacy mode on", "The bot sees addressed group messages without consuming ambient traffic.", "Required"]
]},
{ title: "Choose delivery", intro: "Webhook and polling are mutually exclusive.", rows: [
["Production", "HTTPS webhook with Telegram secret-token verification.", "Webhook"],
["Private Paperclip", "Use the outbound relay to reach the same verified webhook handler.", "Use relay"],
["Local development", "One long-running poller; Paperclip removes any webhook first.", "Advanced"]
]},
{ title: "Connect token and chats", intro: "The bot token is a Paperclip secret reference and can be rotated independently.", rows: [
["Bot token", "Secret ref · telegram/maya-bot ·•••• 471A", "Replace"],
["Webhook URL and secret", "Generated endpoint and secret-token header value.", "Copy"],
["Add Maya to chats", "Invite the bot to groups or forums; topic administration is optional.", "Instructions"]
]},
{ title: "Verify and activate", intro: "Verification checks both provider state and the expected addressed-message behavior.", rows: [
["Bot identity", "getMe → @maya_acme_bot", "Passed"],
["Delivery", "getWebhookInfo · URL · secret · zero pending updates", "Passed"],
["Privacy and membership", "Privacy on · 2 allowed chats · forum topic access", "Passed"],
["Test message", "Send a DM or addressed group message before activation.", "Send test"]
]}
],
setupAnnotations: [
"Agent and unique Telegram bot username are the first binding decision.",
"BotFather owns creation and profile controls; privacy mode stays on by default.",
"Webhook, relay, and local polling are shown as mutually exclusive delivery paths.",
"The token is a masked secret reference, while Telegram chat membership remains an external step.",
"Activation verifies getMe, webhook or polling state, privacy, membership, pending updates, and a test message."
],
overviewSections: [
{ title: "Connection", intro: "One BotFather identity represents Maya in the allowed chats.", rows: [
["Status", "Active · last update 1 minute ago", "Healthy"],
["Agent", "Maya · Support engineer", "Open agent"],
["Telegram identity", "@maya_acme_bot", "Open Telegram"]
]},
{ title: "Bot and delivery", intro: "Webhook state and BotFather policy are monitored separately.", rows: [
["Reach", "Operations group, Support forum / topic 381, DMs", "3 scopes"],
["Ingress", "Verified webhook · zero pending updates", "Healthy"],
["Bot policy", "Privacy on · may join groups", "Current"]
]},
{ title: "Available automatically", intro: "Paperclip uses every safe Telegram capability valid for the current chat type.", rows: [
["Conversation", "DM/group active task plus stable forum-topic binding.", "Included"],
["Output", "Typing or reaction receipt, throttled post/edit, private-chat draft previews when available.", "Included"],
["Rich interaction", "Inline callback and URL buttons, Markdown rendering, documents and media groups.", "Included"],
["Fallback", "Unsupported ephemeral, modal, or select interaction becomes reply or DM plus link.", "Automatic"]
]},
{ title: "Lifecycle", intro: "Operate the BotFather token and connector without changing task bindings.", rows: [
["Connection test", "Verify bot, delivery mode, privacy, membership, and response.", "Test"],
["Maintenance", "Pause, rotate the token, repair membership, or remove the endpoint.", "Manage"]
]}
],
settingsSections: [
{ title: "Chat and participant reach", intro: "Allowed IDs narrow Telegram membership and BotFather group policy.", rows: [
["Allowed chats", "Operations group and Support forum", "2 chats"],
["Allowed topics", "Support forum · topic 381", "1 topic"],
["Direct messages", "People may start active tasks in private chat.", "Allowed"],
["Allowed users", "No additional principal allowlist inside the saved chat scope.", "All scoped"]
]},
{ title: "Task boundaries", intro: "Linear chats use an explicit active issue instead of pretending Telegram has Slack-style threads.", rows: [
["Direct message", "First message starts the active issue; New task or /new starts another.", "Active task"],
["Ordinary group", "@maya activates; replies to Maya or new mentions continue.", "Addressed"],
["Forum topic", "Stable message_thread_id maps one topic to one issue.", "Topic"]
]},
{ title: "BotFather policy and delivery", intro: "Paperclip reports the deployment-selected path; it is not an endpoint preference.", rows: [
["Delivery path", "Selected from instance reachability and verified continuously.", "Automatic"],
["Privacy mode", "Remain on so unrelated group traffic is not consumed.", "Required"],
["Token rotation", "Replace the secret reference after rotating with BotFather.", "Manage secret"],
["Delivery changes", "Paperclip drains pending updates if instance delivery changes.", "Automatic"]
]}
],
settingsAnnotations: [
"Chat, topic, DM, and optional user reach are real scope choices.",
"DM/group active tasks and forum-topic bindings make Telegram's non-Slack boundaries explicit.",
"Delivery is read-only status; privacy mode and token rotation are the only provider operations exposed here. Telegram capabilities are reported on Overview and demonstrated in the walkthrough, never configured here."
],
accessSections: [
{ title: "Endpoint sponsor", intro: "Sponsor authority is bounded by saved chat, topic, and participant reach.", rows: [
["Sponsor", "Dana · Company admin", "Change sponsor"],
["Endpoint scope", "Support project · 2 chats and 1 topic", "View scope"]
]},
{ title: "Linked Telegram people", intro: "Telegram numeric user ID is authoritative; username is display metadata.", rows: [
["Ari · 58104412", "ari@acme.com · Member · confirmed Sep 3", "Revoke"],
["Sam · 59110284", "sam@acme.com · Viewer · confirmed Sep 4", "Revoke"],
["Link another person", "Send an expiring sign-in link in a private response.", "Create link"]
]},
{ title: "Unlinked people", intro: "Unlinked group and DM participants remain sponsored external principals.", rows: [
["Restricted guest profile", "Comment, attach bounded media, and receive safe output inside an allowed issue.", "Default"],
["Governance", "No approvals, budgets, hiring, permissions, connection management, or reassignment.", "Denied"]
]},
{ title: "Telegram identity rules", intro: "Forwarded messages, anonymous admins, and bots require explicit handling.", rows: [
["Anonymous/forwarded actor", "Do not infer a Paperclip user when a stable sender identity is absent.", "Restricted"],
["Other bots", "Ignore unless an explicit endpoint route and loop guards permit the message.", "Ignored"]
]}
],
conversationRows: [
["DM · Ari / @maya_acme_bot", "PAP-1854 · Customer export", "Ari · Working · 1m", "Open Telegram"],
["Operations group · active task", "PAP-1850 · Alert routing", "Sam + 3 · Waiting · 22m", "Open Telegram"],
["Support forum · topic 381", "PAP-1846 · Refund queue", "Ari + 5 · Done · 2h", "Open Telegram"]
],
conversationBoundary: "DM and ordinary group chats expose the active issue. Forum message_thread_id supplies a stable one-topic/one-issue key.",
activityRows: [
["Inbound update", "Upd88422 · message · Ari DM", "Delivered · 1m"],
["Outbound publication", "Pub92G · post → 3 edits → final", "Delivered · 48s"],
["Callback query", "Cb814 · inline New task · Ari", "Authorized · 12m"],
["Inbound update", "Upd88411 · unrelated privacy-on group message", "Not delivered · expected"]
],
activityHealth: ["Bot API · healthy", "Webhook · healthy", "Pending updates · 0", "Flood control · normal"]
}
];
for (const provider of providers) {
provider.overviewAnnotations = sharedAnnotations.overview;
provider.accessAnnotations = sharedAnnotations.access;
provider.conversationAnnotations = sharedAnnotations.conversations;
provider.activityAnnotations = sharedAnnotations.activity;
}

View File

@ -0,0 +1,146 @@
export const providerScreens = [
{
id: "13", slug: "slack-setup", provider: "Slack", phase: "Setup", group: "Slack", kind: "providerSetup",
title: "Invite Maya to Slack", subtitle: "Create or select a Slack app, then verify its workspace installation.",
rationale: "Paperclip generates the exact provider handoff while keeping Slack-owned installation and workspace policy visible.",
annotations: [
"The selected Paperclip agent and derived Slack bot identity stay fixed throughout setup.",
"Direct webhook is the default; relay and Socket Mode are advanced alternatives for private deployments.",
"The generated manifest owns the exact scopes, events, interactivity URL, and optional command configuration.",
"Secrets are masked references; workspace install or OAuth happens in Slack, not inside a Paperclip imitation.",
"Verification separates identity, signature, scopes, events, and workspace membership so failures are actionable."
]
},
{
id: "14", slug: "slack-settings", provider: "Slack", phase: "Configuration", group: "Slack", kind: "providerSettings",
title: "Slack settings", subtitle: "Choose reach and rich behavior after the bot is connected.",
rationale: "Slack exposes the richest optional surface, but least-privilege thread behavior remains the default.",
annotations: [
"Reach is the intersection of the saved allowlist and channels where Slack has actually added the bot.",
"Root mention → Slack thread → one Paperclip issue is fixed; bound-thread replies continue without mentions.",
"DMs, Agent Sessions, progress cadence, files, Block Kit, modals, commands, and ephemeral replies are independent controls.",
"OAuth/Grid identity, token rotation, scope drift, and optional Socket Mode live under Security and delivery.",
"Unsupported or ungranted features show a precise fallback and reinstall action instead of failing silently."
]
},
{
id: "15", slug: "slack-interactions", provider: "Slack", phase: "Interactions", group: "Slack", kind: "providerInteractions",
title: "Slack interaction model", subtitle: "A root mention moves the work into one native thread and one Paperclip issue.",
rationale: "This makes the Hermes thread contract and Slack-specific acknowledgement/action deadlines inspectable.",
annotations: [
"A human mentions @maya in a channel root; an unmentioned fresh root message is ignored.",
"Paperclip durably records and acknowledges the event before task work begins.",
"Maya replies under the activation message; that Slack thread binds exactly one assigned Paperclip issue.",
"Later human replies, files, and actions in the bound thread become turns after current permission checks.",
"Safe streaming, stop/actions, final delivery, and error fallback stay in the thread; internal traces never publish."
]
},
{
id: "16", slug: "github-setup", provider: "GitHub", phase: "Setup", group: "GitHub", kind: "providerSetup",
title: "Connect Maya to GitHub conversations", subtitle: "Install a least-privilege GitHub App on selected repositories.",
rationale: "GitHub chat setup deliberately excludes code/tool authority and makes repository installation scope explicit.",
annotations: [
"The purpose is Chat with an agent; repository code access remains a separate GitHub tool connection.",
"GitHub App is recommended; PAT is marked testing-only and GitHub Enterprise adds an API base URL.",
"Paperclip provides the webhook URL/secret and the minimum Issues, Pull requests, and Metadata permissions.",
"The operator installs the App on selected repositories and stores the App ID/private key as secret references.",
"Verification checks signature delivery, bot identity, subscribed events, installation, and selected repositories."
]
},
{
id: "17", slug: "github-settings", provider: "GitHub", phase: "Configuration", group: "GitHub", kind: "providerSettings",
title: "GitHub conversation settings", subtitle: "Choose repositories, activation surfaces, and comment behavior.",
rationale: "The settings reflect GitHub's object-based threads and its narrower non-realtime interaction surface.",
annotations: [
"The Paperclip repository allowlist can only narrow the repositories selected in the GitHub App installation.",
"Issues, PR conversations, and inline review-comment threads are distinct activation surfaces and bindings.",
"Mention-only activation is the default; labels or trusted-author automation are explicit advanced policies.",
"Output uses GFM, reactions, and coarse comment edits; files and governed actions become Paperclip links.",
"Permission drift, installation suspension, GHES URL, rate limits, and self-message suppression are operational settings."
]
},
{
id: "18", slug: "github-interactions", provider: "GitHub", phase: "Interactions", group: "GitHub", kind: "providerInteractions",
title: "GitHub interaction model", subtitle: "A mention binds the existing issue, PR, or review thread to one Paperclip issue.",
rationale: "GitHub supplies the conversation object, so Paperclip binds it rather than manufacturing a new native thread.",
annotations: [
"A user mentions the bot in an issue, PR conversation, or inline review comment.",
"Webhook signature and delivery ID are verified before principal, repository, and activation checks.",
"The existing GitHub object/thread maps once to a Paperclip issue; an inline review thread remains separate from the PR conversation.",
"Maya reacts, posts or edits one GFM progress comment, and publishes the final answer without token streaming.",
"Buttons, modals, ephemeral replies, DMs, and uploads fall back to text plus authenticated Paperclip URLs."
]
},
{
id: "19", slug: "teams-setup", provider: "Microsoft Teams", phase: "Setup", group: "Teams", kind: "providerSetup",
title: "Install Maya in Microsoft Teams", subtitle: "Register the app and bot, then install its package in the tenant.",
rationale: "Teams setup exposes every external ownership boundary: Entra/bot registration, endpoint, package, tenant policy, and install.",
annotations: [
"Paperclip fixes Maya and supplies the public messaging endpoint before the operator enters Microsoft tooling.",
"Teams Developer CLI is the recommended handoff; manual Azure/Developer Portal setup remains available.",
"Client secret and federated identity are mutually exclusive; single-tenant, multi-tenant, and sovereign cloud are explicit.",
"Custom-app upload or tenant approval may block installation and is reported as an external admin action.",
"Verification covers Entra/bot identity, manifest, endpoint reachability, install scope, and tenant."
]
},
{
id: "20", slug: "teams-settings", provider: "Microsoft Teams", phase: "Configuration", group: "Teams", kind: "providerSettings",
title: "Microsoft Teams settings", subtitle: "Configure chat scopes and add privileged Graph access only when needed.",
rationale: "Teams permissions are layered; basic mention/reply must work without broad directory or history grants.",
annotations: [
"Personal, team/channel, and group-chat reach is bounded by app installation and Paperclip allowlists.",
"Channel post/reply threads map one issue; DMs and group chats use the stable Teams conversation.",
"Mention-only is default. RSC all-message/history access is a per-resource, off-by-default grant.",
"User directory lookup and DM history show their broader Entra application permission and admin-consent status.",
"Adaptive Cards, task modules, targeted messages, files, and DM streaming expose exact group/channel fallbacks."
]
},
{
id: "21", slug: "teams-interactions", provider: "Microsoft Teams", phase: "Interactions", group: "Teams", kind: "providerInteractions",
title: "Microsoft Teams interaction model", subtitle: "The native conversation type determines threading, streaming, and permissions.",
rationale: "Teams channel posts, group chats, and DMs need visibly different runtime behavior behind one endpoint.",
annotations: [
"A channel root mention starts work in that post's reply thread; the original post is the stable thread root.",
"A DM or group-chat message binds the stable Teams conversation according to the configured task-boundary policy.",
"Paperclip verifies the bot activity, resolves tenant/member identity, and applies current access before waking Maya.",
"DMs can stream natively; group/channel output buffers or edits and uses Adaptive Cards/task modules for actions.",
"RSC-disabled unmentioned traffic is ignored; denied or unsupported actions use targeted/DM or text-link fallback."
]
},
{
id: "22", slug: "telegram-setup", provider: "Telegram", phase: "Setup", group: "Telegram", kind: "providerSetup",
title: "Connect Maya to Telegram", subtitle: "Create a dedicated bot with BotFather, then choose webhook or polling delivery.",
rationale: "Telegram has no managed installation object, so bot identity, delivery mode, privacy, and chat membership are separate checks.",
annotations: [
"One BotFather bot represents one Paperclip agent; name, username, avatar, and token come from Telegram.",
"Privacy mode stays on and group joining is allowed; commands and forum-topic rights are optional provider setup.",
"Verified webhook is production default; polling is for local long-running development and cannot run simultaneously.",
"Paperclip supplies the HTTPS webhook URL and secret token, while the operator adds the bot to intended chats.",
"Verification checks getMe identity, webhook/polling exclusivity, pending updates/errors, privacy guidance, and chat reach."
]
},
{
id: "23", slug: "telegram-settings", provider: "Telegram", phase: "Configuration", group: "Telegram", kind: "providerSettings",
title: "Telegram settings", subtitle: "Make task boundaries explicit for DMs, groups, and forum topics.",
rationale: "Telegram's privacy mode and mostly linear chats require different continuation rules from Slack-style subscribed threads.",
annotations: [
"Allowed chats, topics, and users narrow the bot token's reach; privacy mode remains a visible safety assumption.",
"DMs keep one active issue; /new or New task starts another, and /close ends the active binding.",
"Groups activate on @maya and continue only through replies to the bot or new mentions; forum topics can map one issue each.",
"Post-and-edit is default; native draft previews are private-chat-only and opt-in with a rate-safe cadence.",
"Inline buttons, files, callback limits, no ephemeral/modal/select support, flood control, and bot-to-bot off are explicit."
]
},
{
id: "24", slug: "telegram-interactions", provider: "Telegram", phase: "Interactions", group: "Telegram", kind: "providerInteractions",
title: "Telegram interaction model", subtitle: "Privacy-safe replies and explicit New task controls replace universal native threads.",
rationale: "The flow distinguishes private chats, ordinary groups, and forum topics rather than pretending Telegram behaves like Slack.",
annotations: [
"In a DM, the first message creates the active issue; /new or an inline button deliberately starts a fresh one.",
"In a privacy-on group, @maya activates and a reply to Maya continues; unrelated group traffic is not consumed.",
"In a forum, message_thread_id provides a stable topic-to-issue boundary when the bot is present.",
"Paperclip deduplicates update_id, enforces actor/chat scope, then uses typing/reaction and throttled output.",
"Inline callbacks carry opaque IDs; unsupported or governed interactions return concise text/DM plus a Paperclip link."
]
}
];

View File

@ -0,0 +1,89 @@
# Platform-specific chat adapter research source
**Internal synthesis source — do not publish as the reviewer-facing artifact.**
**Date:** 2026-09-04
**Paperclip base:** `origin/master` at `8430bd897f01dd4b91e0970efffb71b97e5a2685`
**Chat SDK snapshot:** `51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c`
This is an implementation research snapshot. The current executable contract is the browser runbook and platform-surfaces companion; upstream capabilities described here are not automatically shipped Paperclip capabilities.
## Research question
What provider-specific setup, configuration, and interaction behavior must Paperclip expose for Slack, GitHub, Discord, Microsoft Teams, and Telegram while preserving the shared rule that Paperclip owns agents, issues, runs, permissions, publications, and audit history?
## Claim-gap matrix
| Claim needed for the product plan | Primary evidence | Confidence | Product consequence |
| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Slack can use verified HTTP webhooks, OAuth installations, or Socket Mode. | [Chat SDK Slack adapter at the pinned revision](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-slack/README.md), [Slack request verification](https://docs.slack.dev/authentication/verifying-requests-from-slack), [Slack Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/) | High | Paperclip selects direct verified callback or its relay from instance reachability. Socket Mode is an instance-admin escape hatch, never an endpoint-wizard choice. |
| Slack events and interactive payloads must be acknowledged quickly. | [Slack Events API](https://docs.slack.dev/apis/events-api/), [Slack interactivity](https://docs.slack.dev/interactivity/handling-user-interaction/) | High | Persist first, acknowledge immediately, process asynchronously; modal-opening callbacks require a fast path because trigger IDs expire. |
| Slack gives the fullest Chat SDK interaction surface. | [Pinned Slack adapter feature table](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-slack/README.md), [Slack Agent Sessions](https://docs.slack.dev/ai/agent-sessions/) | High | Native threads, streaming, Block Kit, actions, modals, slash commands, files, DMs, reactions, and ephemeral responses are used automatically whenever the installation, conversation, and Paperclip authorization permit them. |
| A GitHub App is the least-privilege production credential. | [GitHub App registration](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app), [choosing GitHub App permissions](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app), [pinned GitHub adapter setup](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-github/README.md) | High | Recommend a GitHub App with Issues/PR write and Metadata read, installed only on selected repositories. PAT is a development-only fallback. |
| GitHub chat does not imply repository-code tool access. | GitHub permissions are independently selectable and installation repositories are scoped in [GitHub App permissions](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). | High | The chat connector does not request Contents permission. An agent that must read/write code receives a separate tool-purpose connection and grant. |
| GitHub's native conversation unit is the existing issue, PR conversation, or review-comment thread. | [Pinned GitHub adapter thread model](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-github/README.md), [issue comments API](https://docs.github.com/en/rest/issues/comments) | High | A mention binds an existing GitHub object/thread to one Paperclip issue. Paperclip does not create an extra chat thread. PR-level and inline review threads remain distinct. |
| Teams setup includes app/bot registration, a reachable messaging endpoint, a customer-owned Teams app, and tenant installation policy. | [Teams app registration quickstart](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register), [pinned Teams adapter setup](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-teams/README.md) | High | Provide the exact endpoint and manifest settings; require the customer-owned Entra/Azure Bot/Teams app path; detect disabled sideloading without promising a Paperclip-generated package. |
| Teams normally receives mentions; subscribed channel/group replies require the shipped RSC entries. | [Teams all-message/RSC guidance](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-for-bots-and-agents), [resource-specific permissions](https://learn.microsoft.com/en-us/graph/api/resources/resourcespecificpermission?view=graph-rest-1.0) | High | The customer-owned Teams app includes `ChannelMessage.Read.Group` and `ChatMessage.Read.Chat` for fixed addressed-thread behavior. They are not endpoint toggles and do not grant tenant-wide Graph history/directory access. |
| Some Teams Graph operations require broader Entra permissions and admin consent. | [Graph chat message permissions](https://learn.microsoft.com/en-us/graph/api/chatmessage-get?view=graph-rest-1.0), [pinned Teams adapter history/user lookup notes](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-teams/README.md) | High | `User.Read.All` and DM history remain optional, visibly privileged add-ons. The basic mention/reply experience must not depend on them. |
| Discord bots receive messages and interactions through a long-lived Gateway client. | [Discord Gateway documentation](https://docs.discord.com/developers/events/gateway), [pinned Discord adapter](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-discord/README.md) | High | Use a direct outbound Gateway runtime. Do not request a webhook URL, interactions public key, slash-command setup, or endpoint delivery choice for the current feature set. |
| Discord message content and channel actions require explicit intent and effective bot permissions. | [Discord Gateway intents](https://docs.discord.com/developers/events/gateway#gateway-intents), [Discord OAuth2](https://docs.discord.com/developers/topics/oauth2) | High | Verify Message Content Intent, server installation, and per-channel permissions. Generate only the `bot`-scope install URL with the reviewed permission integer; provider availability remains the ceiling on Paperclip access. |
| Telegram webhooks and long polling are mutually exclusive, and webhooks support a secret-token header. | [Telegram Bot API `getUpdates` and `setWebhook`](https://core.telegram.org/bots/api), [pinned Telegram adapter modes](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-telegram/README.md) | High | Cloud/public instances use verified webhooks; local long-running development may poll; private production uses the Paperclip relay rather than an unverified webhook. |
| Telegram privacy mode changes which group replies the bot receives. | [Telegram bot privacy FAQ](https://core.telegram.org/bots/faq), [Telegram bot features](https://core.telegram.org/bots/features) | High | Keep privacy mode on. A group starts work with `@bot`; continuing turns must reply to the bot's message or mention it. Forum topics can use a stable topic boundary. |
| Telegram's Chat SDK surface is narrower and rate-sensitive. | [Pinned Telegram feature/streaming table](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-telegram/README.md), [Telegram bot FAQ](https://core.telegram.org/bots/faq) | High | Default to throttled post-and-edit, use native drafts only in private chats when enabled, use inline buttons instead of modals/selects, and never promise ephemeral messages. |
| Telegram supports commands as a platform, but the pinned Chat SDK adapter does not expose the shared slash-command feature. | [Telegram commands](https://core.telegram.org/bots/features#commands), [pinned adapter feature table](https://github.com/vercel/chat/blob/51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c/packages/adapter-telegram/README.md) | High | Parse a small Paperclip command vocabulary (`/new`, `/status`, `/close`) as normal messages in Telegram glue until the SDK adapter exposes command events. |
## Provider synthesis
### Slack
- **External setup:** create or select a Slack app; use a generated manifest for the exact bot scopes and events; set the Paperclip webhook URL for Events, Interactivity, and optional slash commands; install to a workspace; store bot/OAuth credentials through Paperclip secret references; verify bot identity, signature, scopes, events, and workspace. Managed Add to Slack remains optional. For Enterprise Grid, installation identity may be the enterprise rather than a single team.
- **Default behavior:** a root `@bot` mention activates the endpoint. The first bot reply under that root establishes the Slack thread and the Paperclip issue binding. Human replies in the bound thread continue without another mention. A DM conversation is a stable issue boundary.
- **Automatic capability behavior:** Agent Sessions/native streaming and stop, Block Kit actions, modals, slash commands, files, and ephemeral denials are used whenever supported and authorized. App Home/suggested prompts are installation concerns. Socket Mode is an instance-admin delivery escape hatch, not an endpoint setting or response-feature switch.
- **Operational caveats:** acknowledge events and actions within Slack's deadline; persist delivery before asynchronous processing; dedupe by event ID; ignore the bot's own messages; surface missing scopes and bot-not-in-channel distinctly; token rotation and OAuth reinstallation must not change the endpoint identity.
### GitHub
- **External setup:** create a GitHub App with webhook URL/secret, `application/json`, Issues read/write, Pull requests read/write, and Metadata read; subscribe to Issue comment and Pull request review comment; generate/store the App private key; install to selected repositories. GitHub Enterprise Server adds an API base URL. PAT is a visibly non-production fallback.
- **Credential separation:** the chat-purpose app does not request Contents, Actions, Administration, or other repository tool permissions. If Maya needs code access during a Paperclip run, `/apps` creates a separate GitHub tool connection with its own credential audience and agent grant.
- **Default behavior:** `@bot` in an issue comment or PR conversation binds that existing issue/PR to one Paperclip issue. An inline review-comment thread has a different stable key and binds separately. Further human comments in the bound context continue the task; self-authored bot comments and redeliveries are suppressed.
- **Rendering:** GitHub-Flavored Markdown, reactions, comment edits, and links. No native streaming, DMs, ephemeral messages, modals, or Chat SDK buttons/selects. Progress should update one bot comment at a coarse cadence; governed actions link to authenticated Paperclip pages. The current adapter treats URLs in inbound comments as ordinary text and does not ingest them as files. Outbound artifacts become Paperclip URLs because the adapter has no file-upload surface.
### Microsoft Teams
- **External setup:** Paperclip provides the messaging endpoint and required manifest settings. The operator creates the single-tenant Entra app, Azure Bot, and customer-owned Teams app in Microsoft, then enters client ID, tenant ID, and client secret in Paperclip. Custom app upload/install must be allowed. No provisioning helper is shipped; Paperclip does not currently generate a package or install link. Paperclip verifies Entra/bot identity, endpoint reachability, tenant mode, and installation activity.
- **Authentication:** the portable customer-owned path uses a client ID, tenant ID, and client secret for a single-tenant registration. Federated workload identity and sovereign/GCC deployment remain future instance-level concerns, not endpoint choices.
- **Default behavior:** personal, team, and group-chat scopes are enabled as installed; direct mentions are the least-privilege trigger. In a Teams channel, a new post and its replies are one native thread and one Paperclip issue. A group chat or DM uses its stable conversation identity.
- **Permissions:** the shipped customer-owned app manifest includes the resource-specific entries required for subscribed channel/group replies. Broader Entra permissions such as `User.Read.All` and `Chat.Read.All` are not required by the basic connector and are not exposed as endpoint controls.
- **Rendering:** Adaptive Cards, buttons, task-module/modal interactions, reactions, typing, and targeted-message/DM fallback. Paperclip's durable webhook pipeline sets native Teams streaming to false and uses bounded post/edit output in DMs, channels, and groups. Native file receipt/upload is personal-chat-only; channel/group files require a separate Graph connection and otherwise fall back to a safe Paperclip link without ingestion. Select menus and slash commands fall back to text or cards.
### Discord
- **External setup:** create one customer-owned application bot per immutable Paperclip agent, enable Message Content Intent, and enter Application ID, Server ID, and bot token. Paperclip generates a server-pinned install URL with only the `bot` scope and the reviewed permission integer, then verifies application identity, intent, membership, and effective text-channel permissions.
- **Transport:** a long-lived outbound Gateway client receives messages, reactions, interactions, edits, and deletes. Discord therefore needs no public Paperclip callback, interactions public key, or endpoint delivery selector. Reconnect, resume, and provider `retry_after` timing are runtime behavior.
- **Default behavior:** a root `@bot` mention creates a Discord public thread and exactly one Paperclip task; replies in that thread continue it without another mention. Direct messages use separate linear task generations. A globally unique Discord Application ID prevents one provider bot identity from representing multiple Paperclip agents.
- **Rendering:** bounded post/edit output, embeds, supported buttons, reactions, and native files are automatic when permitted. The current connector does not advertise slash commands, modals, true ephemeral responses, or proactive DMs.
- **Qualification boundary:** Paperclip preflights endpoint, resource, principal, and root-message admission before provider-thread creation, so a denied root creates no Discord thread or Paperclip work. An allowed root persists a provisional receipt before the provider effect, and recovery idempotently creates or reuses the thread, including existing-thread error `160004`. Those guarantees have deterministic and fresh-database evidence but still require real-provider fault-path proof.
### Telegram
- **External setup:** create one bot per Paperclip agent with BotFather; set name and an available username; paste the bot token into Paperclip. Paperclip calls `getMe`, registers suggested commands, and selects verified webhook, relay, or local-development polling from the deployment. The operator adds the bot to intended chats and keeps privacy mode on. Forum-topic creation requires additional admin rights and is optional.
- **Default privacy:** privacy mode stays on. In a group, an `@bot` message activates a task; follow-ups must reply to a bot message or mention it. Unaddressed group traffic is invisible/ignored. A forum topic is a stable task boundary when present.
- **Linear conversation boundary:** the first DM creates the active Paperclip issue. `/new` or the **New task** inline button closes/shelves the current binding and starts another; `/status` and `/close` are parsed from normal Telegram messages. Ordinary groups without topics use the activation message/reply chain as the visible conversation, while Paperclip stores the explicit active binding.
- **Rendering:** typing/reaction acknowledgement, throttled post-and-edit by default, native draft preview in DMs when available, MarkdownV2/rich-message fallback, inline buttons and URL buttons, files/media. No ephemeral response, modal, select, or list-threads support. Permission denials use a concise normal reply or DM.
- **Operational caveats:** dedupe by `update_id`; configure `allowed_updates`; show pending webhook updates and last error; respect per-chat/group flood limits; callback payloads contain only opaque short IDs; rotate a leaked bot token through BotFather and update the secret reference.
## Cross-provider decisions resulting from the research
1. The shared two-decision onboarding remains intact. Provider complexity belongs in the provider handoff and post-connect settings, not in a universal wizard.
2. Each setup surface follows one vertical sequence while clearly labeling what Paperclip provides and what must be completed at the provider. Paperclip can generate manifests, URLs, secrets, and copyable commands, but it cannot pretend that tenant/workspace/repository installation policy is under Paperclip control.
3. Capability status is rendered from the adapter registry on Overview. Response features are not endpoint controls: the runtime uses the maximum safe supported behavior and explains the exact fallback when provider support, installation permissions, conversation type, or Paperclip authorization prevent it.
4. Provider permission escalation is incremental. Basic mention/reply operation uses the smallest viable permission set; history, all-message visibility, user-directory lookup, and code/tool access are separate grants.
5. Conversation boundaries are provider-native and explicit: Slack thread, GitHub object/review thread, Teams post thread or conversation, Discord public thread or DM generation, Telegram DM/group active binding or forum topic.
6. Every provider-specific interaction still enters the same durable delivery → principal/permission → issue binding → Paperclip wakeup → safe publication flow.
## Residual validation before implementation
- Confirm the exact Slack app manifest against the Chat SDK version finally pinned for implementation, including any optional Agent Sessions scopes/events.
- Test Teams channel reply events in mention-only mode and document precisely when subscribed replies require RSC versus ordinary bot conversation delivery.
- Test Telegram privacy-on reply delivery, forum-topic identifiers, native draft support, and effective file-download ceilings against the production Bot API version.
- Confirm whether the GitHub adapter should include Discussion comments in the first release; the pinned adapter documents issues, PR conversations, and review comments, so Discussions should remain out of the launch promise unless implemented and tested.
- Run provider sandbox fixtures for redelivery, edits/deletes, bot self-messages, credential revocation, permission drift, and uninstall/reinstall identity stability.

View File

@ -0,0 +1,5 @@
# Current-state references
No screenshots are committed in this planning snapshot. On 2026-09-04 the current staging `/apps` catalog, `/apps/connect` wizard, existing agent picker, and connector-detail `permissions` view were visually inspected as layout references. Staging content is mutable and includes account-specific data, so it is not persisted as a design asset.
The current v8 wireframes reuse the observed Paperclip patterns: global and contextual sidebars, the compact purpose choice, the existing agent selector, and connector-detail navigation. Provider setup uses the supplied step-rail screenshot only as a layout reference; its text and functionality are not copied, and the account-specific screenshot is not added to this package. Agent detail and task detail continue to follow the checked-in source because deterministic local fixtures were not available.

View File

@ -0,0 +1,156 @@
export const fixedBehavior = [
[
"Channel activation",
"A root mention creates a provider-native thread and one Paperclip task on Slack and Teams. Replies in that thread continue the same task without another mention."
],
[
"Existing provider thread",
"The first mention inside an unbound Slack or Teams thread binds that existing thread to one new Paperclip task. Earlier messages are not imported automatically."
],
[
"Direct messages",
"One open task is active in a DM. A completed task stays closed; the next message starts a new task. New task or /new starts another task explicitly."
],
[
"GitHub conversations",
"A mention binds the addressed issue, pull-request conversation, or inline review thread to one Paperclip task."
],
[
"Telegram conversations",
"DMs and ordinary groups use one active task. A forum topic has one stable topic-to-task binding."
],
[
"Delivery",
"Paperclip chooses direct verified webhooks when reachable and the instance relay when private. This is deployment configuration, not an endpoint preference."
],
[
"Credentials and drift",
"Invalid credentials, revoked installs, missing membership, or permission drift appear in Activity with a reconnect or repair action. They are not ordinary settings."
]
];
export const providerSettings = {
Slack: {
id: "14",
slug: "slack-settings",
short: "Slack",
title: "Slack settings",
subtitle: "Choose where people can start conversations with Maya.",
sections: [
{
kind: "resources",
title: "Allowed channels",
intro: "Maya responds only in the selected channels.",
items: [
["#customer-support · Acme", "Maya is already a member"],
["#incidents · Acme", "Maya is already a member"]
],
action: "Edit allowed channels"
},
{
kind: "toggles",
title: "Direct messages",
intro: "Control whether people can start work privately.",
items: [
["Allow direct messages", "A person can start a task by messaging Maya directly.", true]
]
}
],
annotations: [
"The connector starts on Settings; the read-only Overview tab is removed.",
"Workspace appears only as context on each allowed channel; allowed channels are the only Slack resource choice.",
"Direct messages are one explicit on/off choice.",
"Save persists only reach changes; thread boundaries, delivery, credentials, drift, and capabilities are absent."
]
},
GitHub: {
id: "17",
slug: "github-settings",
short: "GitHub",
title: "GitHub settings",
subtitle: "Choose the repositories where people can mention Maya.",
sections: [
{
kind: "resources",
title: "Allowed repositories",
intro: "Paperclip can narrow, but not exceed, the GitHub App installation.",
items: [
["acme/api · GitHub", "Installed and allowed"],
["acme/web · GitHub", "Installed and allowed"]
],
action: "Edit allowed repositories"
}
],
annotations: [
"The connector starts on Settings; the read-only Overview tab is removed.",
"The account and App installation are fixed; repository reach is the only normal GitHub chat setting.",
"Save persists the repository allowlist; private-key or installation repair begins from Activity only when needed."
]
},
"Microsoft Teams": {
id: "20",
slug: "teams-settings",
short: "Teams",
title: "Microsoft Teams settings",
subtitle: "Choose where people can start conversations with Maya.",
sections: [
{
kind: "resources",
title: "Allowed channels",
intro: "Maya responds only in the selected Teams channels.",
items: [
["Support / General · Acme", "Maya is installed in this team"]
],
action: "Edit allowed channels"
},
{
kind: "toggles",
title: "Private conversations",
intro: "Choose which non-channel conversations may start work.",
items: [
["Allow direct messages", "A person can start a task in a personal chat with Maya.", true],
["Allow group chats", "People can add Maya to a group chat and start a task there.", false]
]
}
],
annotations: [
"The connector starts on Settings; the read-only Overview tab is removed.",
"Tenant and bot identity are fixed; the tenant appears only as context on allowed Teams channels.",
"Personal and group chats are independent reach toggles.",
"Save persists only reach changes; post boundaries, consent, delivery, credentials, and drift are absent."
]
},
Telegram: {
id: "23",
slug: "telegram-settings",
short: "Telegram",
title: "Telegram settings",
subtitle: "Choose where people can start conversations with Maya.",
sections: [
{
kind: "resources",
title: "Allowed chats and topics",
intro: "Maya responds only in the selected Telegram destinations.",
items: [
["Operations group · Telegram", "Group chat"],
["Support forum / Refunds · Telegram", "Forum topic"]
],
action: "Edit allowed chats"
},
{
kind: "toggles",
title: "Direct messages",
intro: "Control whether people can start work privately.",
items: [
["Allow direct messages", "A person can start a task in a private chat with Maya.", true]
]
}
],
annotations: [
"The connector starts on Settings; the read-only Overview tab is removed.",
"Allowed groups and forum topics are the Telegram resource choice.",
"Direct messages are one explicit on/off choice.",
"Save persists only reach changes; task boundaries, privacy, delivery, token rotation, and health are absent."
]
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,345 @@
export const setupFlows = [
{
provider: "Slack",
short: "Slack",
screens: [
{
id: "13",
slug: "slack-add",
title: "Connect a Slack app",
subtitle: "Bring your own Slack app using Paperclip's prepared manifest.",
rail: ["Agent selected", "Connect Slack app", "Try Maya"],
active: 1,
mode: "default",
instructions: [
["Copy the manifest", "Create a Slack app From an app manifest in the target workspace."],
["Install the app", "Open OAuth & Permissions, install it to the workspace, and copy the Bot User OAuth Token."],
["Copy the signing secret", "Open Basic Information and copy the App's Signing Secret."]
],
fields: [
["Bot User OAuth Token", "xoxb-••••••••••••"],
["Signing Secret", "••••••••••••"]
],
primary: "Connect Slack app",
secondary: "Open Slack app settings",
actions: [
["Connect Slack app", "Stores the two write-only credentials and verifies the Slack bot identity and required scopes."],
["Open Slack app settings", "Opens Slack's app-management page where the operator creates and installs the customer-owned App."]
],
annotations: [
"The prepared manifest and exact provider locations make the customer-owned App the complete required path.",
"Only the Bot User OAuth Token and Signing Secret are entered, and both remain write-only.",
"Managed Add to Slack is an optional later convenience and cannot gate this path or release."
],
rationale: "Bring-your-own credentials are sufficient to ship; managed installation remains optional."
},
{
id: "41",
slug: "slack-try",
title: "Try Maya in Slack",
subtitle: "Start one task and reply to it once.",
rail: ["Agent selected", "Connect Slack app", "Try Maya"],
active: 2,
mode: "default",
instructions: [
["Open a channel", "If Slack asks, add Maya with /invite @Maya."],
["Start a task", "Post “@Maya help me test this” as a new channel message."],
["Continue the task", "Reply once in the thread Maya creates; no second mention is needed."]
],
primary: "Open Slack",
actions: [
["Open Slack", "Opens the installed workspace while Paperclip waits for the root mention and thread reply to complete setup."]
],
annotations: [
"The body is only the three actions needed to test the real Slack interaction.",
"The instructions teach the root-mention-to-thread Paperclip task boundary.",
"There is one action: open Slack and perform the test."
],
rationale: "Installation health and automatic verification do not belong on an instruction screen."
}
]
},
{
provider: "GitHub",
short: "GitHub",
screens: [
{
id: "16",
slug: "github-create",
title: "Create Maya in GitHub",
subtitle: "Create a dedicated GitHub App from Paperclip's prepared manifest.",
rail: ["Agent selected", "Create GitHub App", "Choose repositories", "Try Maya"],
active: 1,
mode: "default",
instructions: [
["Choose the owner", "Select your personal account or the organization that should own the App."],
["Create the App", "Keep the suggested unique name, then click Create GitHub App."]
],
primary: "Create in GitHub",
secondary: "Use an existing GitHub App",
actions: [
["Create in GitHub", "Posts Paperclip's App Manifest to GitHub. GitHub returns to Paperclip after creation, and Paperclip stores the returned App credentials."],
["Use an existing GitHub App", "Opens the advanced path for an App the organization already owns."]
],
annotations: [
"Only the two choices GitHub presents during App creation are described.",
"The normal action uses the GitHub App Manifest handoff; credentials never pass through the operator.",
"The existing-App branch remains available without cluttering the default path."
],
rationale: "The manifest already fixes permissions, events, and webhook configuration."
},
{
id: "45",
slug: "github-install",
title: "Choose GitHub repositories",
subtitle: "Install Maya where people should be able to mention it.",
rail: ["Agent selected", "Create GitHub App", "Choose repositories", "Try Maya"],
active: 2,
mode: "default",
instructions: [
["Choose the account or organization", "GitHub may ask an organization owner to approve the installation."],
["Choose repository access", "Select all repositories or only the repositories where Maya should respond."],
["Install", "Review the requested chat permissions, then click Install."]
],
primary: "Install in GitHub",
actions: [
["Install in GitHub", "Opens GitHub's App installation page and returns the installation and selected repository IDs to Paperclip."]
],
annotations: [
"The screen contains only GitHub's installation decisions.",
"Repository scope stays in GitHub's native approval UI.",
"One button begins the complete provider-owned installation step."
],
rationale: "There is no Paperclip form to duplicate GitHub's repository picker."
},
{
id: "46",
slug: "github-try",
title: "Try Maya in GitHub",
subtitle: "Start one task in an installed repository.",
rail: ["Agent selected", "Create GitHub App", "Choose repositories", "Try Maya"],
active: 3,
mode: "default",
instructions: [
["Open an issue or pull request", "Use one of the repositories selected during installation."],
["Mention Maya", "Add a comment: “@paperclip-maya help me test this.”"],
["Continue", "Add another comment in the same issue or pull request to continue the same Paperclip task."]
],
primary: "Open GitHub",
actions: [
["Open GitHub", "Opens an installed repository while Paperclip waits for the first signed mention to complete setup."]
],
annotations: [
"The body is only the native GitHub test sequence.",
"The instructions explain that GitHub's existing issue or pull request is the task boundary.",
"There is one action: open GitHub and perform the test."
],
rationale: "A real mention proves the App installation without a separate verification screen."
},
{
id: "47",
slug: "github-existing",
title: "Connect an existing GitHub App",
subtitle: "Update the App in GitHub, then provide its identity credentials.",
rail: ["Agent selected", "Configure existing App", "Choose repositories", "Try Maya"],
active: 1,
mode: "advanced",
copyValue: ["Webhook URL and secret", "Copy Paperclip webhook settings"],
instructions: [
["Update the webhook", "In the GitHub App settings, paste Paperclip's URL and generated secret, then make the webhook active."],
["Set permissions and events", "Grant Issues: write, Pull requests: write, Metadata: read; subscribe to Issue comment and Pull request review comment."],
["Create a private key", "In the App settings, click Generate a private key and download the PEM file."]
],
fields: [
["GitHub App ID", "123456"],
["Private key", "Choose PEM file"]
],
primary: "Connect and verify",
secondary: "Back",
actions: [
["Copy Paperclip webhook settings", "Copies the endpoint URL and generated webhook secret needed in the existing GitHub App settings."],
["Connect and verify", "Stores the PEM file write-only, authenticates as the App, and verifies webhook, events, and least-privilege permissions."],
["Back", "Returns to the credential-free App Manifest path."]
],
annotations: [
"The copy control provides the exact values the operator must paste into GitHub.",
"The instructions list every provider change required for an existing App.",
"Only App ID and private key return to Paperclip; the generated webhook secret is already stored.",
"Verification happens as part of Connect rather than on another screen."
],
rationale: "Existing Apps lack the manifest callback, so this advanced page contains the complete minimum manual configuration."
}
]
},
{
provider: "Microsoft Teams",
short: "Teams",
screens: [
{
id: "19",
slug: "teams-register",
title: "Create Maya for Microsoft Teams",
subtitle: "Run one command to register Maya with Microsoft.",
rail: ["Agent selected", "Create Teams app", "Install Maya", "Try Maya"],
active: 1,
mode: "default",
code: "npx @paperclipai/teams-connect --setup PC-7K4M",
instructions: [
["Copy and run the command", "Run it in a terminal on a computer where you can sign in to Microsoft 365."],
["Sign in to Microsoft", "Approve the Microsoft login when the browser opens. The command creates the bot and returns here when it is ready."]
],
primary: "Copy setup command",
secondary: "Set up Microsoft manually",
actions: [
["Copy setup command", "Copies a one-time Paperclip command that invokes Microsoft's Teams Developer CLI, signs the operator in, creates the Teams App and bot registration, and sends the resulting identity to this setup draft."],
["Set up Microsoft manually", "Opens the Azure/Teams manual fallback for tenants that cannot run the guided command."]
],
annotations: [
"The generated command is the only normal-path configuration artifact.",
"Both instructions are actions the operator performs locally or in Microsoft's login.",
"The manual path is available without exposing Azure choices on the default screen."
],
rationale: "The helper collapses Microsoft registration into one attended command while Microsoft remains the authority for sign-in and tenant policy."
},
{
id: "49",
slug: "teams-install",
title: "Install Maya in Microsoft Teams",
subtitle: "Open the Microsoft install page and add the app.",
rail: ["Agent selected", "Create Teams app", "Install Maya", "Try Maya"],
active: 2,
mode: "default",
instructions: [
["Open the install page", "Sign in to the same Microsoft 365 tenant if prompted."],
["Add Maya", "Review the app, click Add, and choose the team or channel if Microsoft asks."]
],
primary: "Install Maya in Teams",
actions: [
["Install Maya in Teams", "Opens the install link returned by Microsoft. Tenant policy may route the same request to an administrator for approval."]
],
annotations: [
"The install link replaces package download and upload on the normal path.",
"The body contains only the two actions performed in Microsoft Teams.",
"Tenant approval is handled by Microsoft's install experience, not another Paperclip choice."
],
rationale: "Microsoft's CLI returns an install link, so normal setup should use it directly."
},
{
id: "50",
slug: "teams-try",
title: "Try Maya in Microsoft Teams",
subtitle: "Start one task in a channel post.",
rail: ["Agent selected", "Create Teams app", "Install Maya", "Try Maya"],
active: 3,
mode: "default",
instructions: [
["Open an installed channel", "Start a new post rather than replying to an unrelated post."],
["Mention Maya", "Post “@Maya help me test this.”"],
["Continue in replies", "Reply once beneath that post; the post and its replies are one Paperclip task."]
],
primary: "Open Microsoft Teams",
actions: [
["Open Microsoft Teams", "Opens Teams while Paperclip waits for the first authenticated mention and reply to complete setup."]
],
annotations: [
"The body is only the Teams channel test sequence.",
"The instructions teach the channel-post-and-replies task boundary.",
"There is one action: open Teams and perform the test."
],
rationale: "The final provider event is the verification; no installation report is shown first."
},
{
id: "48",
slug: "teams-manual",
title: "Set up Microsoft manually",
subtitle: "Create the bot in Microsoft, then paste the three identity values.",
rail: ["Agent selected", "Configure Microsoft", "Install Maya", "Try Maya"],
active: 1,
mode: "advanced",
copyValue: ["Messaging endpoint", "Copy Paperclip endpoint"],
instructions: [
["Create the Microsoft identity", "Create a single-tenant Entra App registration and a client secret."],
["Create the bot", "Create an Azure Bot with that App ID, enable the Microsoft Teams channel, and paste Paperclip's messaging endpoint."],
["Enter the identity below", "Copy Application ID and Directory ID from Entra; paste the client secret value before leaving Microsoft."]
],
fields: [
["Application (client) ID", "00000000-0000-0000-0000-000000000000"],
["Directory (tenant) ID", "00000000-0000-0000-0000-000000000000"],
["Client secret", "••••••••••••"]
],
primary: "Connect and create Teams app",
secondary: "Back",
actions: [
["Copy Paperclip endpoint", "Copies the public messaging endpoint that must be entered on the Azure Bot resource."],
["Connect and create Teams app", "Stores the client secret write-only, verifies Microsoft bot authentication, and creates the installable Teams app and install link."],
["Back", "Returns to the guided one-command setup."]
],
annotations: [
"The copy control provides the one Paperclip value required by Microsoft.",
"Every instruction is a portal operation the tenant administrator must perform.",
"The three fields are the minimum identity values Paperclip needs to send as the bot.",
"Connect verifies the identity and produces the same install step as the default flow."
],
rationale: "The manual fallback is longer because Microsoft has no manifest callback equivalent; no optional Azure choices are exposed."
}
]
},
{
provider: "Telegram",
short: "Telegram",
screens: [
{
id: "22",
slug: "telegram-create",
title: "Create Maya in Telegram",
subtitle: "Create the bot with BotFather and paste its token.",
rail: ["Agent selected", "Create Telegram bot", "Try Maya"],
active: 1,
mode: "default",
instructions: [
["Open BotFather", "Send /newbot."],
["Name the bot", "Enter Maya, then choose an available username ending in bot."],
["Copy the token", "BotFather sends a token after creating the bot. Paste it below."]
],
fields: [
["Bot token", "123456:••••••••••••"]
],
primary: "Connect bot",
secondary: "Open BotFather",
actions: [
["Open BotFather", "Opens Telegram's verified BotFather conversation so the operator can run /newbot."],
["Connect bot", "Stores the token write-only, verifies the bot with getMe, and continues to the test step."]
],
annotations: [
"The page contains the exact three BotFather actions.",
"The bot token is Telegram's only unavoidable setup input.",
"The two buttons let the operator leave for BotFather and connect after returning."
],
rationale: "Webhook, polling, commands, and identity checks are automatic and therefore absent."
},
{
id: "51",
slug: "telegram-try",
title: "Try Maya in Telegram",
subtitle: "Send the bot its first message.",
rail: ["Agent selected", "Create Telegram bot", "Try Maya"],
active: 2,
mode: "default",
instructions: [
["Open Maya", "Telegram opens the new bot's private chat."],
["Start the chat", "Tap Start, then send “Help me test this.”"]
],
primary: "Open Maya in Telegram",
actions: [
["Open Maya in Telegram", "Opens the bot's t.me link while Paperclip waits for the first verified private message to complete setup."]
],
annotations: [
"The minimum proof is one private message; group and forum reach can be added after connection.",
"The body contains only the two Telegram actions required for the test.",
"There is one action: open the bot and send the message."
],
rationale: "A private chat is Telegram's shortest path from BotFather token to a working Paperclip conversation."
}
]
}
];

View File

@ -0,0 +1,275 @@
import { setupFlows as v6SetupFlows } from "./setup-wireframe-data-v6.mjs";
const githubSetup = {
provider: "GitHub",
short: "GitHub",
screens: [
{
id: "16",
slug: "github-create",
title: "Create or connect a GitHub App",
subtitle:
"Bring your own dedicated GitHub App and verify it with Paperclip.",
rail: ["Agent selected", "Connect GitHub App", "Try Maya"],
active: 1,
mode: "default",
instructions: [
[
"Generate the webhook secret",
"Paperclip stores a random 32-byte secret and shows it once. Paste it with the Paperclip webhook URL into GitHub and keep SSL verification enabled.",
],
[
"Set minimum permissions and events",
"Grant Metadata: read, Issues: read and write, and Pull requests: read and write. Subscribe only to Issue comment and Pull request review comment.",
],
[
"Create and install the App",
"Choose the owning user or organization, keep user authorization off, then install the App on only the repositories where people may mention the agent.",
],
[
"Return the App credentials",
"Paste the numeric App ID and private-key PEM into Paperclip. Connect remains unavailable until GitHub has delivered a correctly signed webhook ping.",
],
],
values: [
[
"Paperclip webhook URL",
"https://paperclip.example/api/chat-webhooks/…/github",
],
["Webhook secret", "Generated by Paperclip · shown once"],
],
fields: [
["GitHub App ID", "123456"],
["Private key (PEM)", "Write-only PEM"],
],
actions: [
[
"Generate webhook secret",
"Creates and stores the webhook secret, then exposes its one-time copy value.",
],
[
"Open new GitHub App form",
"Opens GitHub App registration; GitHub remains the authority for App ownership and repository installation.",
],
[
"Connect and verify",
"Authenticates with the App ID and private key, verifies the immutable App identity, required permissions and events, installation, and signed webhook ping.",
],
],
annotations: [
"The customer-owned App path is the complete shipped setup; no managed App Manifest exchange is required.",
"Paperclip generates the webhook secret and never returns it from normal endpoint reads.",
"Grant Metadata read, Issues and Pull requests read/write, plus Issue comment and Pull request review comment events; installation lifecycle events are automatic.",
"GitHub installation scope and Paperclip repository enablement remain independent reach controls.",
],
rationale:
"Bring-your-own App credentials are sufficient to ship and preserve one provider bot identity per Paperclip agent.",
},
{
id: "46",
slug: "github-try",
title: "Try Maya in GitHub",
subtitle: "Start one task in an installed repository.",
rail: ["Agent selected", "Connect GitHub App", "Try Maya"],
active: 2,
mode: "default",
instructions: [
[
"Open an issue or pull request",
"Use one of the repositories selected in the customer-owned App installation.",
],
[
"Mention Maya",
"Add a comment that directly mentions the verified GitHub App bot.",
],
[
"Continue the task",
"Add another comment in the same issue or pull request without repeating the mention.",
],
],
actions: [
[
"Open GitHub",
"Opens an installed repository while Paperclip waits for the first signed mention and follow-up to complete setup.",
],
],
annotations: [
"The test uses the real GitHub issue or pull-request conversation boundary.",
"The first addressed setup repository becomes enabled; other discovered repositories remain disabled.",
"One external conversation maps to one Paperclip task.",
],
rationale:
"A signed provider round trip proves installation, reach, identity, and conversation continuity.",
},
],
};
// v8 also replaced the helper-first Teams draft. Keep the current generator
// from overwriting that settled customer-owned setup while it consumes the
// historical v6 data for unchanged providers.
const teamsSetup = {
provider: "Microsoft Teams",
short: "Teams",
screens: [
{
id: "19",
slug: "teams-register",
title: "Create Maya for Microsoft Teams",
subtitle: "Register a customer-owned Entra App and Azure Bot.",
rail: ["Agent selected", "Connect Teams app", "Install Maya", "Try Maya"],
active: 1,
mode: "default",
instructions: [
[
"Copy the messaging endpoint",
"Use Paperclip's public callback in the Azure Bot configuration.",
],
[
"Create the Microsoft resources",
"Create the single-tenant Entra App, client secret, Azure Bot, and customer-owned Teams app in Microsoft's portals.",
],
[
"Return the identity values",
"Paste the Application ID, Directory/Tenant ID, and client-secret value into Paperclip.",
],
],
actions: [
[
"Copy messaging endpoint",
"Copies the public callback for Azure Bot configuration.",
],
[
"Open Microsoft setup",
"Opens Microsoft's provider-owned registration surfaces.",
],
[
"Connect and verify",
"Stores the client secret write-only and verifies the tenant and application identity.",
],
],
annotations: [
"Paperclip provides the exact public messaging endpoint.",
"The operator creates the single-tenant Entra App, Azure Bot, and Teams app in Microsoft.",
"A future helper is optional and cannot gate the customer-owned path or release.",
],
rationale:
"Bring-your-own credentials are the required portable setup path.",
},
{
id: "49",
slug: "teams-install",
title: "Install Maya in Microsoft Teams",
subtitle:
"Publish or upload the customer-owned app, then add it in Teams.",
rail: ["Agent selected", "Connect Teams app", "Install Maya", "Try Maya"],
active: 2,
mode: "default",
instructions: [
[
"Open Teams Developer Portal",
"Complete the app metadata and publish it or download it for tenant-approved custom upload.",
],
[
"Install the app",
"Use Microsoft's installation flow in the intended personal, team, or group-chat scope.",
],
],
actions: [
[
"Open Teams Developer Portal",
"Opens the provider-owned app surface; tenant policy may require administrator approval.",
],
],
annotations: [
"Microsoft owns app creation, packaging, publication, approval, and installation.",
"Paperclip does not generate a complete Teams package or promise an install link.",
"Tenant approval remains in Microsoft's install experience.",
],
rationale:
"Customer-owned registration is required; Microsoft owns the app artifact and installation.",
},
{
id: "50",
slug: "teams-try",
title: "Try Maya in Microsoft Teams",
subtitle: "Start one task in a channel post.",
rail: ["Agent selected", "Connect Teams app", "Install Maya", "Try Maya"],
active: 3,
mode: "default",
instructions: [
[
"Open an installed channel",
"Start a new post rather than replying to an unrelated post.",
],
["Mention Maya", "Mention the installed bot in the new post."],
[
"Continue in replies",
"Reply once beneath that post; the post and replies are one Paperclip task.",
],
],
actions: [
[
"Open Microsoft Teams",
"Opens Teams while Paperclip waits for the authenticated mention and reply.",
],
],
annotations: [
"The body is only the Teams channel test sequence.",
"The instructions teach the channel-post-and-replies task boundary.",
"There is one action: open Teams and perform the test.",
],
rationale: "The final provider event is the verification.",
},
{
id: "48",
slug: "teams-manual",
title: "Microsoft provider setup details",
subtitle:
"Create the customer-owned bot and app, then paste the three identity values.",
rail: ["Agent selected", "Connect Teams app", "Install Maya", "Try Maya"],
active: 1,
mode: "default",
instructions: [
[
"Create the Microsoft identity",
"Create a single-tenant Entra App registration and a client secret.",
],
[
"Create the bot and Teams app",
"Create the Azure Bot, configure Paperclip's endpoint, and create the customer-owned Teams app in Developer Portal.",
],
[
"Enter the identity below",
"Return the Application ID, Directory ID, and client-secret value to Paperclip.",
],
],
actions: [
[
"Copy Paperclip endpoint",
"Copies the public messaging endpoint for the Azure Bot resource.",
],
[
"Connect and verify",
"Stores the client secret write-only and verifies Microsoft bot authentication.",
],
["Back", "Returns to the primary customer-owned credential setup."],
],
annotations: [
"The endpoint is the one Paperclip-specific value required by Microsoft.",
"Every instruction is a provider portal operation.",
"The three identity fields are the minimum credentials Paperclip needs.",
"Connect does not generate a Teams package or install link.",
],
rationale:
"This is reference detail for the required customer-owned path; a future helper remains optional.",
},
],
};
export const setupFlows = v6SetupFlows.map((flow) =>
flow.provider === "GitHub"
? githubSetup
: flow.provider === "Microsoft Teams"
? teamsSetup
: flow,
);

View File

@ -0,0 +1,14 @@
# Historical chat-adapter wireframes
All generated wireframe images and their HTML gallery are excluded from the PR.
The final 67 v8 SVGs and gallery remain recoverable in the
[pre-removal archive](https://github.com/paperclipai/paperclip/tree/63c8b5d8d0671d9f0676a8f4b0d0ac52d0353884/doc/plans/chat-adapters).
The 455 generated v1-v7 SVGs were removed earlier; their exact contents remain
available in the [pre-prune archive commit](https://github.com/paperclipai/paperclip/tree/1c4a45f0ef7d627aa98e4f3ae3116d4507386d1a/doc/plans/chat-adapters).
Written plans, implementation, tests, and production provider icons remain.
The superseded [v2 design note](https://github.com/paperclipai/paperclip/blob/e72a504800c0945993736574fb3cb3bf81dd5157/doc/plans/chat-adapters/2026-09-04-chat-adapters-ui-surfaces-v2.md), [v3 design note](https://github.com/paperclipai/paperclip/blob/9668530e14d42f715fa5778c5e14bfe0fed2a018/doc/plans/chat-adapters/2026-09-04-chat-adapters-ui-surfaces-v3.md) and [v4 design note](https://github.com/paperclipai/paperclip/blob/9668530e14d42f715fa5778c5e14bfe0fed2a018/doc/plans/chat-adapters/2026-09-04-chat-adapters-ui-surfaces-v4.md) are also archived in Git history. This retains their exact decisions while keeping the combined implementation and upstream compatibility fixes within the 500-file review limit. Current designs and live qualification records remain in the working tree; the historical regeneration inputs remain in the archive below.
The retained generators are an ordered chain, not standalone snapshot builders; in particular, the v8 generator reads v7 outputs. For historical regeneration, use a scratch checkout of the pre-prune archive commit above so all snapshot notes and outputs are present. Run `node generate-wireframes.mjs` for v1 independently; then run `node generate-wireframes-v2.mjs`, `node generate-provider-wireframes.mjs`, and `node generate-wireframes-v3.mjs` through `node generate-wireframes-v8.mjs` in numeric order. These scripts also overwrite viewer/specification files such as `index.html`, so do not run them in a working tree with documentation changes you intend to keep.
The superseded [v5 design note](https://github.com/paperclipai/paperclip/blob/c52e98c9be57683004040ce59f037091bd4e54d9/doc/plans/chat-adapters/2026-09-04-chat-adapters-ui-surfaces-v5.md) also remains available in Git history. Its separate setup audit stays in the working tree. The v6 surface note and minimum-setup specification remain here too; v6 was restored byte-for-byte when the CI-owned lockfile delta was removed. Archiving only v5 makes room for the pinned Discord WebSocket shutdown fix without splitting this review or dropping implementation/tests.

View File

@ -19,6 +19,7 @@ All environment variables that Paperclip uses for server configuration.
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. |
| `PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL` | (board public origin) | Optional HTTPS origin for native chat provider webhooks when ingress and the board use different hosts. Must have no credentials, path, query, or fragment; invalid configuration refuses startup. Used only for provider callback URLs, not board links, authentication, trusted hosts, or identity confirmation. |
| `PAPERCLIP_RUNNER_PUBLIC_URL` | (unset) | Explicit `wss://` base URL used only when a remote `paperclip_runner` target dials Paperclip directly. Paperclip appends `/api/runner/v1/connect/<runId>`; the reverse proxy must forward WebSocket upgrades for that route. This value is never inferred from request headers. Daytona ignores it and uses provider ingress. |
| `PAPERCLIP_RUNNER_CA_BUNDLE_PATH` | (unset) | Optional PEM CA bundle for direct runner WSS. Platform roots remain enabled. There is no insecure TLS bypass. |
| `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` | (host build) | Host-local path to a `paperclip-runnerd` artifact built for the remote target OS and architecture. Required when Paperclip and the remote sandbox do not share a compatible platform; build metadata and the required transport mode are verified before launch. |
@ -37,6 +38,20 @@ key remains accepted in stored and managed configuration for version-skew
compatibility, but it has no runtime effect. The setting has no effect on
legacy adapters or callback bridges.
### Webhook-only chat ingress
Keep `PAPERCLIP_PUBLIC_URL` (or the explicit authentication public URL) pointed
at the actual board. If the board is private, set
`PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL=https://chat-ingress.example.com` and forward
only `POST /api/chat-webhooks/*` from that host. Provider signatures still gate
ingress; this variable does not expose routes or grant provider access.
Never forward the private `local_trusted` board through a public tunnel.
Task links in external messages require an externally safe HTTPS board URL.
Local/private board URLs are omitted with instructions to open the task in
Paperclip; the public webhook host is never substituted for the board. Identity
confirmation stays on the board and requires the user to be able to reach it.
### Preinstalled remote runner images
Remote sandbox images may preinstall `paperclip-runnerd`, `codex`, and the

View File

@ -28,6 +28,21 @@ npx paperclipai instance settings:experimental:update --payload-json '{...}'
Those commands change the same opt-in settings that the UI manages.
## Chat connectors
**Chat connectors** is off by default. Enable it to connect a dedicated
Slack, GitHub, Microsoft Teams, Telegram, or Discord bot to one Paperclip
agent. The experiment shows chat setup, connection management, agent channels,
and external task controls.
When it is off, existing production tool connectors remain available. For
example, GitHub opens its normal tool connection flow without asking you to
choose between chat and tools.
This setting controls visibility. Turning it off does not disconnect an
existing bot or stop its messages. To stop a connection, pause it from its
chat connection settings before turning off the experiment.
## When to use them
Experimental features are best used when you are:

View File

@ -592,6 +592,148 @@ describe("shared ACPX engine runtime behavior", () => {
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
});
it("uses only the guarded external-chat contract for a default ACPX prompt", async () => {
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
{
authToken: "runtime-secret-token",
context: {
taskId: "issue-chat-1",
paperclipTaskMarkdown: "# CHAT-1 — Answer the provider message",
paperclipWake: {
reason: "External chat message received",
externalChatProvider: "discord",
checkedOutByHarness: true,
issue: {
id: "issue-chat-1",
identifier: "CHAT-1",
title: "Discord conversation",
status: "in_progress",
workMode: "standard",
},
continuationSummary: {
key: "summary",
body: "Earlier chat context.",
},
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
comments: [
{
id: "comment-chat-1",
issueId: "issue-chat-1",
body: "Reply with CHAT-OK.",
},
],
fallbackFetchNeeded: false,
},
},
},
);
const prompt = String(meta[0]?.prompt ?? "");
const promptMetrics = meta[0]?.promptMetrics as Record<string, number> | undefined;
expect(prompt).toContain("## External chat response contract");
expect(prompt).toContain("# CHAT-1 — Answer the provider message");
expect(prompt).toContain("Make zero Paperclip API calls");
expect(prompt).not.toContain("Paperclip API access note:");
expect(prompt).not.toContain("Paperclip runtime note:");
expect(prompt).not.toContain(
"Leave durable progress in comments, documents, or work products",
);
expect(promptMetrics?.runtimeNoteChars).toBe(0);
expect(promptMetrics?.heartbeatPromptChars).toBe(0);
});
it("keeps the authenticated API fallback when ACPX has no native wake reader", async () => {
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
{
authToken: "runtime-secret-token",
context: {
taskId: "issue-chat-overflow",
paperclipTaskMarkdown: "# CHAT-2 — Answer every queued message",
paperclipWake: {
reason: "External chat message received",
externalChatProvider: "slack",
checkedOutByHarness: true,
issue: {
id: "issue-chat-overflow",
identifier: "CHAT-2",
title: "Slack conversation",
status: "in_progress",
workMode: "standard",
},
commentWindow: {
requestedCount: 2,
includedCount: 1,
missingCount: 1,
},
commentIds: ["comment-chat-1", "comment-chat-2"],
latestCommentId: "comment-chat-2",
comments: [
{
id: "comment-chat-2",
issueId: "issue-chat-overflow",
body: "Answer both queued messages.",
},
],
fallbackFetchNeeded: true,
},
},
},
);
const prompt = String(meta[0]?.prompt ?? "");
expect(prompt).not.toContain("read_current_wake_comments");
expect(prompt).not.toContain("## External chat response contract");
expect(prompt).toContain("Paperclip API access note:");
expect(prompt).toContain("Only fetch the API thread");
});
it("preserves a configured agent prompt template on a guarded external-chat turn", async () => {
const { meta } = await runExecutor(
{
agent: "custom",
agentCommand: "node ./fake-acp.js",
promptTemplate: "Custom agent instruction for {{agent.id}}.",
},
{
context: {
taskId: "issue-chat-1",
paperclipWake: {
reason: "External chat message received",
externalChatProvider: "telegram",
checkedOutByHarness: true,
issue: {
id: "issue-chat-1",
identifier: "CHAT-1",
title: "Telegram conversation",
status: "in_progress",
workMode: "standard",
},
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
comments: [{ id: "comment-chat-1", body: "Hello" }],
fallbackFetchNeeded: false,
},
},
},
);
const prompt = String(meta[0]?.prompt ?? "");
expect(prompt).toContain("## External chat response contract");
expect(prompt).toContain("Custom agent instruction for agent-1.");
expect(prompt).not.toContain(
"Leave durable progress in comments, documents, or work products",
);
});
it("does not show a scoped issue API command when the task id is unavailable", async () => {
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js" },

View File

@ -54,6 +54,7 @@ import {
ensurePathInEnv,
ensurePaperclipSkillSymlink,
isForbiddenConfigEnvKey,
isPaperclipExternalChatTurn,
isPaperclipRuntimeEnvKey,
joinPromptSections,
materializePaperclipSkillCopy,
@ -2819,7 +2820,11 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
commandNotes: string[];
}> {
const { agent, runId, config, context, onLog } = ctx;
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
const configuredPromptTemplate = asString(config.promptTemplate, "");
const hasCustomPromptTemplate = configuredPromptTemplate.trim().length > 0;
const promptTemplate = hasCustomPromptTemplate
? configuredPromptTemplate
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
@ -2860,6 +2865,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession });
const externalChatTurn = isPaperclipExternalChatTurn(context.paperclipWake);
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
resumedSession,
// The task-context markdown is the authoritative brief on this lane; keep
@ -2868,10 +2874,13 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
});
const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0;
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const renderedPrompt =
shouldUseResumeDeltaPrompt || (externalChatTurn && !hasCustomPromptTemplate)
? ""
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const paperclipEnvNote = externalChatTurn ? "" : renderPaperclipEnvNote(env);
const apiAccessNote = externalChatTurn ? "" : renderApiAccessNote(env);
const prompt = joinPromptSections([
promptInstructionsPrefix,
renderedBootstrapPrompt,

View File

@ -7,15 +7,27 @@ import { afterEach, expect, it } from "vitest";
import { createAcpxEngineExecutor } from "./execute.js";
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
const fixturePath = path.join(repoRoot, "scripts", "mcp-fixtures", "servers", "acp-echo-agent.mjs");
const fixturePath = path.join(
repoRoot,
"scripts",
"mcp-fixtures",
"servers",
"acp-echo-agent.mjs",
);
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
await Promise.all(
tempRoots
.splice(0)
.map((root) => fs.rm(root, { recursive: true, force: true })),
);
});
it("spawns a real Node ACP agent with per-session env on this platform", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-spawn-smoke-"));
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-acpx-spawn-smoke-"),
);
tempRoots.push(root);
const stateDir = path.join(root, "state");
const logs: string[] = [];
@ -41,18 +53,159 @@ it("spawns a real Node ACP agent with per-session env on this platform", async (
expect(result.exitCode, JSON.stringify({ result, logs }, null, 2)).toBe(0);
expect(logs.join(""), logs.join("\n")).toContain("spawn-ok");
await expect(fs.access(path.join(stateDir, "wrappers"))).rejects.toThrow();
const stderr = await fs.readFile(path.join(stateDir, "run-stderr", "spawn-smoke.log"), "utf8");
const stderr = await fs.readFile(
path.join(stateDir, "run-stderr", "spawn-smoke.log"),
"utf8",
);
expect(stderr).toContain("nes/close");
expect(stderr).toContain("paperclip-acp-echo-agent started");
});
it("fails closed on a typed ACP session failure without exposing its provider text", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-acpx-typed-failure-"),
);
tempRoots.push(root);
const logs: string[] = [];
const providerText = "provider-error-canary-must-not-become-agent-output";
const execute = createAcpxEngineExecutor();
const result = await execute({
runId: "typed-failure-smoke",
agent: { id: "spawn-agent", companyId: "spawn-company" },
runtime: {},
config: {
agent: "custom",
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`,
mode: "oneshot",
stateDir: path.join(root, "state"),
cwd: repoRoot,
env: { PAPERCLIP_ACPX_TYPED_FAILURE_CANARY: providerText },
},
context: {},
onLog: async (_stream: string, text: string) => logs.push(text),
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_turn_failed");
expect(JSON.stringify(result)).not.toContain(providerText);
expect(logs.join("\n")).not.toContain(providerText);
expect(result.summary).toContain("terminal request failure");
});
it("fails closed on a typed ACP session failure in persistent mode", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-acpx-persistent-typed-failure-"),
);
tempRoots.push(root);
const providerText = "persistent-provider-error-canary-must-not-escape";
const logs: string[] = [];
const execute = createAcpxEngineExecutor();
const result = await execute({
runId: "persistent-typed-failure-smoke",
agent: { id: "spawn-agent", companyId: "spawn-company" },
runtime: {},
config: {
agent: "custom",
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`,
mode: "persistent",
warmHandleIdleMs: 0,
stateDir: path.join(root, "state"),
cwd: repoRoot,
env: { PAPERCLIP_ACPX_TYPED_FAILURE_CANARY: providerText },
},
context: {},
onLog: async (_stream: string, text: string) => logs.push(text),
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_turn_failed");
expect(JSON.stringify(result)).not.toContain(providerText);
expect(logs.join("\n")).not.toContain(providerText);
expect(result.summary).toContain("terminal request failure");
});
it("preserves ordinary assistant text even when it resembles a provider error", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-acpx-error-shaped-answer-"),
);
tempRoots.push(root);
const answer =
'Warning: quoted example follows. {"error":{"type":"invalid_request_error","message":"example only"}}';
const execute = createAcpxEngineExecutor();
const result = await execute({
runId: "error-shaped-answer-smoke",
agent: { id: "spawn-agent", companyId: "spawn-company" },
runtime: {},
config: {
agent: "custom",
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`,
mode: "oneshot",
stateDir: path.join(root, "state"),
cwd: repoRoot,
env: { PAPERCLIP_ACPX_SPAWN_SMOKE: answer },
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
expect(result.summary).toBe(answer);
});
it("keeps a typed retry warning nonfatal when the turn produces an answer", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-acpx-typed-warning-"),
);
tempRoots.push(root);
const answer = "Recovered after the transient connection warning.";
const warningCanary = "typed-warning-is-not-terminal";
const logs: string[] = [];
const execute = createAcpxEngineExecutor();
const result = await execute({
runId: "typed-warning-smoke",
agent: { id: "spawn-agent", companyId: "spawn-company" },
runtime: {},
config: {
agent: "custom",
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`,
mode: "oneshot",
stateDir: path.join(root, "state"),
cwd: repoRoot,
env: {
PAPERCLIP_ACPX_TYPED_WARNING_CANARY: warningCanary,
PAPERCLIP_ACPX_SPAWN_SMOKE: answer,
},
},
context: {},
onLog: async (_stream: string, text: string) => logs.push(text),
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
expect(result.summary).toBe(answer);
expect(JSON.stringify(result)).not.toContain(warningCanary);
expect(logs.join("\n")).not.toContain(warningCanary);
});
it("captures the Node error shape for a host-invalid spawn cwd", async () => {
// Regression anchor for the primitive behind the remote-lane bug: a host
// `spawn()` whose `cwd` does not exist fails BEFORE `exec`, when libuv
// `chdir`s into it. The command itself (`process.execPath`) is valid, so the
// failure is unambiguously the missing cwd — the exact condition acpx hits
// when it host-spawns the relay proxy with the in-sandbox `remoteCwd`.
const missingCwd = path.join(os.tmpdir(), "paperclip-acpx-missing-spawn-cwd", "nested", "does-not-exist");
const missingCwd = path.join(
os.tmpdir(),
"paperclip-acpx-missing-spawn-cwd",
"nested",
"does-not-exist",
);
const err = await new Promise<NodeJS.ErrnoException>((resolve, reject) => {
const child = spawn(process.execPath, ["-e", "0"], {
@ -62,7 +215,11 @@ it("captures the Node error shape for a host-invalid spawn cwd", async () => {
child.once("error", resolve);
child.once("spawn", () => {
child.kill("SIGKILL");
reject(new Error("expected spawn to fail with a host-invalid cwd, but it started"));
reject(
new Error(
"expected spawn to fail with a host-invalid cwd, but it started",
),
);
});
});

View File

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { paperclipChatFilePreparationDelivery } from "./chat-file-delivery.js";
import { renderPaperclipWakePrompt } from "./server-utils.js";
function chatWake(provider: unknown) {
return {
reason: "External chat message received",
externalChatProvider: provider,
checkedOutByHarness: true,
issue: { id: "chat-task", workMode: "standard" },
comments: [
{ id: "comment-1", issueId: "chat-task", body: "Please send the file." },
],
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
fallbackFetchNeeded: false,
};
}
describe("chat file preparation delivery contract", () => {
it.each(["github", "microsoft-teams"])(
"describes %s as task-only, never a native attachment",
(provider) => {
const delivery = paperclipChatFilePreparationDelivery(provider);
expect(delivery).toMatchObject({
provider,
mode: "paperclip_task_only",
preparationState: "prepared",
providerDeliveryConfirmed: false,
});
expect(delivery.guidance).toContain("cannot upload file bytes");
expect(delivery.guidance).toContain(
"must be opened there with Paperclip access",
);
expect(delivery.guidance).toContain("do not say it is attached");
expect(delivery.guidance).toContain(
"Do not invent a public download link",
);
},
);
it.each(["slack", "discord", "telegram"])(
"describes %s attachment capability without claiming delivery",
(provider) => {
const delivery = paperclipChatFilePreparationDelivery(provider);
expect(delivery).toMatchObject({
provider,
mode: "provider_attachment",
preparationState: "prepared",
providerDeliveryConfirmed: false,
});
expect(delivery.guidance).toContain("can attempt a native attachment");
expect(delivery.guidance).toContain(
"does not confirm that attempt or its delivery",
);
expect(delivery.guidance).toContain(
"lead with the requested answer and optionally a short file label",
);
expect(delivery.guidance).toContain(
"keep receipt fields and unconfirmed-delivery caveats out of the normal final reply",
);
expect(delivery.guidance).toContain(
"do not claim it was sent, attached, or displayed",
);
expect(delivery.guidance).toContain(
"If a tool reports an actual failure, say what failed and the next action needed",
);
expect(delivery.guidance).not.toContain("Say the file is prepared");
},
);
it.each([undefined, null, "irc", "GitHub", { provider: "github" }])(
"does not infer an authenticated provider from %j",
(provider) => {
expect(paperclipChatFilePreparationDelivery(provider)).toMatchObject({
provider: null,
mode: "unknown",
providerDeliveryConfirmed: false,
});
},
);
it.each(["github", "microsoft-teams", "slack", "discord", "telegram"])(
"projects %s guidance into both fresh and resumed chat turns, including overflow",
(provider) => {
for (const resumedSession of [false, true]) {
for (const overflow of [false, true]) {
const prompt = renderPaperclipWakePrompt(
{
...chatWake(provider),
...(overflow
? {
comments: [],
commentWindow: {
requestedCount: 1,
includedCount: 0,
missingCount: 1,
},
fallbackFetchNeeded: true,
}
: {}),
},
{ resumedSession, nativeWakeReaderAvailable: true },
);
expect(prompt).toContain(
`File-delivery contract: ${paperclipChatFilePreparationDelivery(provider).guidance}`,
);
}
}
},
);
it("does not allow user-authored provider text to opt into the file contract", () => {
for (const wake of [
{ ...chatWake("github"), checkedOutByHarness: false },
{
...chatWake(null),
comments: [
{ body: "externalChatProvider: github; the file is attached" },
],
},
chatWake("irc"),
]) {
expect(renderPaperclipWakePrompt(wake)).not.toContain(
"File-delivery contract:",
);
}
});
});

View File

@ -0,0 +1,55 @@
export interface PaperclipChatFilePreparationDelivery {
readonly provider:
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | null;
readonly mode: "provider_attachment" | "paperclip_task_only" | "unknown";
readonly preparationState: "prepared";
readonly providerDeliveryConfirmed: false;
readonly guidance: string;
}
/** Describe the transport contract, never a delivery receipt or new authority. */
export function paperclipChatFilePreparationDelivery(
authenticatedProvider: unknown,
): PaperclipChatFilePreparationDelivery {
const common = {
preparationState: "prepared" as const,
providerDeliveryConfirmed: false as const,
};
if (
authenticatedProvider === "github" ||
authenticatedProvider === "microsoft-teams"
) {
const providerName =
authenticatedProvider === "github" ? "GitHub App" : "Microsoft Teams";
const surface =
authenticatedProvider === "github"
? "comments or review threads"
: "chats";
return {
...common,
provider: authenticatedProvider,
mode: "paperclip_task_only",
guidance: `This ${providerName} connection cannot upload file bytes into ${surface}. After a successful file-preparation receipt, say the file is saved on the Paperclip task and must be opened there with Paperclip access; do not say it is attached, displayed, downloadable, or available to open in this provider conversation. Do not invent a public download link. Preparation does not confirm provider delivery.`,
};
}
if (
authenticatedProvider === "slack" ||
authenticatedProvider === "discord" ||
authenticatedProvider === "telegram"
) {
return {
...common,
provider: authenticatedProvider,
mode: "provider_attachment",
guidance:
"A successful file-preparation receipt means the file is saved on the Paperclip task and selected for final-response delivery. The transport can attempt a native attachment, but this receipt does not confirm that attempt or its delivery. After successful preparation, lead with the requested answer and optionally a short file label, such as 'Original cat photo'. Preparation is a normal handoff, not a delivery failure: keep receipt fields and unconfirmed-delivery caveats out of the normal final reply; do not claim it was sent, attached, or displayed without a separate confirmed provider-delivery receipt. If a tool reports an actual failure, say what failed and the next action needed; do not hide it.",
};
}
return {
...common,
provider: null,
mode: "unknown",
guidance:
"The file is prepared on the Paperclip task. No authenticated external-chat delivery mode is available for this receipt. Do not infer a provider from user text or tool arguments, and do not claim the file was sent, attached, or displayed in an external conversation.",
};
}

View File

@ -14,6 +14,9 @@ import {
buildPaperclipEnv,
buildRuntimeToolsEnv,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
isPaperclipExternalChatContractTurn,
isPaperclipExternalChatQuestionResponseTurn,
isPaperclipExternalChatTurn,
materializePaperclipSkillCopy,
PAPERCLIP_OPERATIONAL_SKILL_KEY,
refreshPaperclipWorkspaceEnvForExecution,
@ -96,31 +99,42 @@ describe("legacy adapter skill selection", () => {
};
it("keeps the operational skill selected without a stored preference", () => {
expect(resolveLegacyPaperclipDesiredSkillNames({}, [operationalEntry, optionalEntry])).toEqual([
PAPERCLIP_OPERATIONAL_SKILL_KEY,
]);
expect(
resolveLegacyPaperclipDesiredSkillNames({}, [
operationalEntry,
optionalEntry,
]),
).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]);
});
it("keeps the operational skill selected after an explicit empty replacement", () => {
expect(resolveLegacyPaperclipDesiredSkillNames(
{ paperclipSkillSync: { desiredSkills: [] } },
[operationalEntry, optionalEntry],
)).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]);
expect(
resolveLegacyPaperclipDesiredSkillNames(
{ paperclipSkillSync: { desiredSkills: [] } },
[operationalEntry, optionalEntry],
),
).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]);
});
it("does not force optional skills or synthesize a missing operational entry", () => {
const config = { paperclipSkillSync: { desiredSkills: [optionalEntry.key] } };
expect(resolveLegacyPaperclipDesiredSkillNames(config, [operationalEntry, optionalEntry])).toEqual([
PAPERCLIP_OPERATIONAL_SKILL_KEY,
optionalEntry.key,
]);
expect(resolveLegacyPaperclipDesiredSkillNames(config, [optionalEntry])).toEqual([
optionalEntry.key,
]);
const config = {
paperclipSkillSync: { desiredSkills: [optionalEntry.key] },
};
expect(
resolveLegacyPaperclipDesiredSkillNames(config, [
operationalEntry,
optionalEntry,
]),
).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY, optionalEntry.key]);
expect(
resolveLegacyPaperclipDesiredSkillNames(config, [optionalEntry]),
).toEqual([optionalEntry.key]);
});
it("leaves the configurable resolver available for native runners", () => {
expect(resolvePaperclipDesiredSkillNames({}, [operationalEntry])).toEqual([]);
expect(resolvePaperclipDesiredSkillNames({}, [operationalEntry])).toEqual(
[],
);
});
});
@ -893,6 +907,288 @@ describe("runChildProcess", () => {
});
describe("renderPaperclipWakePrompt", () => {
const ordinaryExternalChatWake = {
reason: "External chat message received",
externalChatProvider: " GitHub ",
checkedOutByHarness: true,
issue: {
id: "issue-chat-1",
identifier: "CHAT-1",
title: "External chat conversation",
description: "Started from GitHub.",
descriptionTruncated: false,
status: "in_progress",
workMode: "standard",
},
continuationSummary: {
key: "summary",
title: "Conversation history",
body: "The previous provider turn completed successfully.",
updatedAt: "2026-09-07T13:59:39.464Z",
},
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
commentIds: ["comment-chat-1"],
latestCommentId: "comment-chat-1",
comments: [
{
id: "comment-chat-1",
issueId: "issue-chat-1",
body: "Reply with the current release marker.",
bodyTruncated: false,
authorType: "user",
},
],
fallbackFetchNeeded: false,
};
it("recognizes only normalized, harness-checked-out ordinary external-chat wakes", () => {
expect(isPaperclipExternalChatTurn(ordinaryExternalChatWake)).toBe(true);
const normalized = JSON.parse(
stringifyPaperclipWakePayload(ordinaryExternalChatWake) ?? "{}",
);
expect(normalized).toMatchObject({
externalChatProvider: "github",
checkedOutByHarness: true,
skillTest: false,
});
expect(isPaperclipExternalChatTurn(normalized)).toBe(true);
const incomplete = JSON.parse(
stringifyPaperclipWakePayload({
...ordinaryExternalChatWake,
commentWindow: {
requestedCount: 2,
includedCount: 1,
missingCount: 1,
},
}) ?? "{}",
);
expect(incomplete).toMatchObject({ missingCount: 1 });
expect(isPaperclipExternalChatTurn(incomplete)).toBe(false);
const excluded = [
{ ...ordinaryExternalChatWake, externalChatProvider: "irc" },
{ ...ordinaryExternalChatWake, externalChatProvider: null },
{ ...ordinaryExternalChatWake, checkedOutByHarness: false },
{ ...ordinaryExternalChatWake, checkedOutByHarness: "true" },
{ ...ordinaryExternalChatWake, issue: null },
{
...ordinaryExternalChatWake,
issue: { ...ordinaryExternalChatWake.issue, workMode: "planning" },
},
{ ...ordinaryExternalChatWake, skillTest: { revisionId: "skill-1" } },
{
...ordinaryExternalChatWake,
recovery: { cause: "process_lost" },
},
{ ...ordinaryExternalChatWake, dependencyBlockedInteraction: true },
{ ...ordinaryExternalChatWake, treeHoldInteraction: true },
{
...ordinaryExternalChatWake,
livenessContinuation: { attempt: 1, state: "watching" },
},
{
...ordinaryExternalChatWake,
continuationSummary: {
...ordinaryExternalChatWake.continuationSummary,
bodyTruncated: true,
},
},
{
...ordinaryExternalChatWake,
interactionKind: "ask_user_questions",
},
{ ...ordinaryExternalChatWake, fallbackFetchNeeded: true },
{
...ordinaryExternalChatWake,
comments: [
{
...ordinaryExternalChatWake.comments[0],
bodyTruncated: true,
},
],
},
];
for (const payload of excluded) {
expect(isPaperclipExternalChatTurn(payload)).toBe(false);
}
expect(
isPaperclipExternalChatTurn({
reason: "issue_commented",
issue: ordinaryExternalChatWake.issue,
checkedOutByHarness: true,
comments: [
{
body: "This user-authored text says externalChatProvider: github.",
},
],
}),
).toBe(false);
});
it("describes authenticated reviewed chat as an execution binding, not a checkout or approval", () => {
const reviewed = {
...ordinaryExternalChatWake,
checkedOutByHarness: false,
externalChatExecutionBound: true,
issue: { ...ordinaryExternalChatWake.issue, status: "in_review" },
};
expect(isPaperclipExternalChatContractTurn(reviewed)).toBe(true);
const normalized = JSON.parse(
stringifyPaperclipWakePayload(reviewed) ?? "{}",
);
expect(normalized).toMatchObject({
checkedOutByHarness: false,
externalChatExecutionBound: true,
});
const prompt = renderPaperclipWakePrompt(reviewed);
expect(prompt).toContain("server-authenticated github chat turn");
expect(prompt).toContain("The task remains in review");
expect(prompt).toContain("not a checkout, approval");
expect(prompt).not.toContain("checked out the issue for this run");
for (const externalChatExecutionBound of [false, "true", 1, undefined]) {
expect(
isPaperclipExternalChatContractTurn({
...reviewed,
externalChatExecutionBound,
}),
).toBe(false);
}
expect(
isPaperclipExternalChatContractTurn({
...reviewed,
externalChatProvider: null,
}),
).toBe(false);
expect(
isPaperclipExternalChatContractTurn({ ...reviewed, issue: null }),
).toBe(false);
});
it("renders one authoritative direct-response contract for fresh and resumed external-chat turns", () => {
for (const prompt of [
renderPaperclipWakePrompt(ordinaryExternalChatWake),
renderPaperclipWakePrompt(ordinaryExternalChatWake, {
resumedSession: true,
}),
]) {
expect(prompt.match(/## External chat response contract/g)).toHaveLength(
1,
);
expect(prompt).toContain("server-authenticated github chat turn");
expect(prompt).toContain("Make zero Paperclip API calls");
expect(prompt).toContain("answer directly");
expect(prompt).toContain("exactly one semantic completion");
expect(prompt).toContain("summary is the user-visible final answer");
expect(prompt).toContain("Private progress commentary is not delivered");
expect(prompt).toContain("any actionable file-access or delivery limitation");
expect(prompt).toContain(
"Keep wait and review dispositions in the semantic control fields",
);
expect(prompt).toContain(
"Mention task state only when the user asks about it or must act on a real blocker",
);
expect(prompt).toContain(
"report `yielded` with continuation kind `response_wake`",
);
expect(prompt).toContain(
"without scheduling more work",
);
expect(prompt).toContain(
"never use it to defer unfinished work",
);
expect(prompt).toContain(
"files, investigation, external access, or mutations",
);
expect(prompt).toContain(
"This response shortcut grants no new authority",
);
expect(prompt).not.toContain(
"a successful process exit or final response is not sufficient",
);
expect(prompt).not.toContain("acknowledge the latest comment");
expect(prompt).not.toContain("checkout: already claimed");
expect(prompt).not.toContain(
"POST /api/issues/$PAPERCLIP_TASK_ID/checkout",
);
}
const freshPrompt = renderPaperclipWakePrompt(ordinaryExternalChatWake);
expect(freshPrompt).toContain(
"Answer the pending comments directly, in order",
);
expect(freshPrompt).toContain("do not omit any request");
expect(freshPrompt).not.toContain(
"explain how it changes your next action",
);
const incompleteResumePrompt = renderPaperclipWakePrompt(
{
...ordinaryExternalChatWake,
continuationSummary: {
...ordinaryExternalChatWake.continuationSummary,
bodyTruncated: true,
},
},
{ resumedSession: true },
);
expect(incompleteResumePrompt).not.toContain(
"## External chat response contract",
);
expect(incompleteResumePrompt).toContain(
"[continuation summary truncated]",
);
expect(incompleteResumePrompt).toContain(
"a successful process exit or final response is not sufficient",
);
});
it("uses only the run-scoped reader when an authenticated chat wake does not fit inline", () => {
const readerWake = {
...ordinaryExternalChatWake,
fallbackFetchNeeded: true,
commentIds: ["comment-chat-1", "comment-chat-2"],
latestCommentId: "comment-chat-2",
commentWindow: {
requestedCount: 2,
includedCount: 1,
missingCount: 1,
},
};
expect(isPaperclipExternalChatTurn(readerWake)).toBe(false);
expect(isPaperclipExternalChatContractTurn(readerWake)).toBe(true);
const genericPrompt = renderPaperclipWakePrompt(readerWake);
expect(genericPrompt).not.toContain("read_current_wake_comments");
expect(genericPrompt).not.toContain("## External chat response contract");
expect(genericPrompt).toContain("fetch the API thread");
for (const prompt of [
renderPaperclipWakePrompt(readerWake, {
nativeWakeReaderAvailable: true,
}),
renderPaperclipWakePrompt(readerWake, {
resumedSession: true,
nativeWakeReaderAvailable: true,
}),
]) {
expect(prompt.match(/## External chat response contract/g)).toHaveLength(
1,
);
expect(prompt).toContain(
"call `read_current_wake_comments` without a cursor",
);
expect(prompt).toContain("until `complete` is true");
expect(prompt).toContain("exact comments accepted for this run");
expect(prompt).toContain("Make zero other Paperclip API calls");
expect(prompt).not.toContain("fetch the API thread");
expect(prompt).not.toContain("refetching the issue thread");
expect(prompt).not.toContain("checkout: already claimed");
}
});
it("preserves and renders the issue description in structured wake payloads", () => {
const payload = {
reason: "issue_assigned",
@ -1228,6 +1524,268 @@ describe("renderPaperclipWakePrompt", () => {
}
});
it.each(["answered", "accepted"])(
"preserves exact-output constraints for an external %s interaction continuation",
(interactionStatus) => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-CHAT",
title: "Continue provider request",
status: "in_progress",
},
interactionKind:
interactionStatus === "answered"
? "ask_user_questions"
: "request_confirmation",
interactionStatus,
externalInteractionContinuation: true,
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
comments: [
{
id: "comment-1",
issueId: "issue-1",
body: "Reply with exactly RELEASE-Saffron and nothing else.",
},
],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("## External interaction continuation");
expect(prompt).toContain(
"Preserve and obey the original source comment's formatting and exact-output constraints literally.",
);
expect(prompt).toContain(
"the externally visible response must contain exactly that and nothing else",
);
expect(prompt).toContain(
"Use internal Paperclip tools to satisfy the task lifecycle, including marking the task done when its requested work is complete.",
);
expect(prompt).toContain(
"Exact-output constraints apply to provider-visible prose, not necessary internal tool calls",
);
expect(prompt).toContain(
"Do not narrate answer receipt, interaction IDs, Paperclip workflow, delegation, task status, or closure",
);
expect(prompt).toContain(
"Reply with exactly RELEASE-Saffron and nothing else.",
);
},
);
const externalQuestionMarker = {
schema: "paperclip.external_chat_question_response.v1",
interactionId: "10000000-0000-4000-8000-000000000001",
responseDeliveryId: "20000000-0000-4000-8000-000000000002",
sourceRunId: "30000000-0000-4000-8000-000000000003",
sourceCommentId: "40000000-0000-4000-8000-000000000004",
endpointId: "50000000-0000-4000-8000-000000000005",
conversationId: "60000000-0000-4000-8000-000000000006",
bindingSha256: "a".repeat(64),
};
const externalQuestionWake = {
...ordinaryExternalChatWake,
reason: "issue_commented",
externalChatProvider: "discord",
checkedOutByHarness: false,
externalChatExecutionBound: true,
issue: { ...ordinaryExternalChatWake.issue, status: "in_review" },
interactionId: externalQuestionMarker.interactionId,
sourceRunId: externalQuestionMarker.sourceRunId,
interactionKind: "ask_user_questions",
interactionStatus: "answered",
externalInteractionContinuation: true,
externalChatQuestionResponse: externalQuestionMarker,
questionResponse: {
interactionId: externalQuestionMarker.interactionId,
summaryMarkdown: "Color: Cobalt",
truncated: false,
},
commentIds: [externalQuestionMarker.sourceCommentId],
latestCommentId: externalQuestionMarker.sourceCommentId,
comments: [
{
...ordinaryExternalChatWake.comments[0],
id: externalQuestionMarker.sourceCommentId,
body: "Ask for a color, then reply with exactly COLOR-<chosen color>.",
},
],
};
it.each([false, true])(
"uses the actual answer in an attested native chat question continuation (resumed: %s)",
(resumedSession) => {
for (const checkedOutByHarness of [false, true]) {
const wake = JSON.parse(
stringifyPaperclipWakePayload({
...externalQuestionWake,
checkedOutByHarness,
externalChatExecutionBound: !checkedOutByHarness,
}) ?? "{}",
);
const prompt = renderPaperclipWakePrompt(wake, { resumedSession });
expect(isPaperclipExternalChatTurn(wake)).toBe(false);
expect(isPaperclipExternalChatQuestionResponseTurn(wake)).toBe(true);
expect(prompt).toContain("## External chat answered-question contract");
expect(prompt).toContain(
"The semantic completion summary is the user-visible final answer.",
);
expect(prompt).toContain(
"Use the authoritative answer below to complete the original request; do not repeat or re-ask the resolved question.",
);
expect(prompt).toContain("Color: Cobalt");
expect(prompt).toContain("COLOR-<chosen color>");
expect(prompt).toContain(
"Original request for context (only the answered questions listed above are resolved):",
);
expect(prompt.indexOf("Color: Cobalt")).toBeLessThan(
prompt.indexOf("Ask for a color"),
);
expect(prompt).not.toContain("New comments in order:");
expect(prompt).toContain(
"This answer resolves only the named question, not a separate approval or completion review.",
);
expect(prompt).toContain(
"do not change task status, clear a review, or manufacture a new wait or monitor",
);
expect(prompt).not.toContain("including marking the task done");
expect(prompt).not.toContain("acknowledge the latest comment");
}
},
);
it("keeps newer comments outside the resolved-question background", () => {
const newerComment = {
...ordinaryExternalChatWake.comments[0],
id: "comment-2",
body: "Also answer this genuinely new follow-up.",
};
const wake = {
...externalQuestionWake,
commentIds: [externalQuestionMarker.sourceCommentId, newerComment.id],
latestCommentId: newerComment.id,
comments: [...externalQuestionWake.comments, newerComment],
commentWindow: {
requestedCount: 2,
includedCount: 2,
missingCount: 0,
},
};
const prompt = renderPaperclipWakePrompt(wake);
expect(isPaperclipExternalChatQuestionResponseTurn(wake)).toBe(true);
expect(prompt).toContain(
"Original request for context (only the answered questions listed above are resolved):",
);
expect(prompt).toContain(
"Other new comments in order (not resolved by the answer above):",
);
expect(prompt.indexOf("Color: Cobalt")).toBeLessThan(
prompt.indexOf("Ask for a color"),
);
expect(prompt.indexOf("Ask for a color")).toBeLessThan(
prompt.indexOf("Also answer this genuinely new follow-up."),
);
});
it("does not grant the native chat answer prompt to malformed or unbound answer metadata", () => {
const invalid = [
{ externalChatQuestionResponse: null },
{
externalChatQuestionResponse: {
...externalQuestionMarker,
schema: "other",
},
},
{
externalChatQuestionResponse: {
...externalQuestionMarker,
extra: true,
},
},
{
externalChatQuestionResponse: {
...externalQuestionMarker,
bindingSha256: "A".repeat(64),
},
},
...[
"interactionId",
"responseDeliveryId",
"sourceRunId",
"sourceCommentId",
"endpointId",
"conversationId",
].map((key) => ({
externalChatQuestionResponse: {
...externalQuestionMarker,
[key]: "not-a-uuid",
},
})),
{ interactionId: externalQuestionMarker.endpointId },
{ interactionId: null },
{ sourceRunId: externalQuestionMarker.endpointId },
{ sourceRunId: null },
{
questionResponse: {
...externalQuestionWake.questionResponse,
interactionId: externalQuestionMarker.endpointId,
},
},
{
questionResponse: {
...externalQuestionWake.questionResponse,
truncated: true,
},
},
{ questionResponse: null },
{ externalChatProvider: "irc" },
{ checkedOutByHarness: false, externalChatExecutionBound: false },
{ externalInteractionContinuation: false },
{ interactionStatus: "pending" },
{ interactionKind: "request_confirmation" },
{ reason: "issue_recovery_action_restored" },
{ recovery: { cause: "process_lost" } },
{ dependencyBlockedInteraction: true },
{ executionStage: { stageId: "review-1", wakeRole: "reviewer" } },
{ issue: { ...externalQuestionWake.issue, workMode: "planning" } },
{ fallbackFetchNeeded: true },
];
for (const override of invalid) {
const wake = { ...externalQuestionWake, ...override };
const prompt = renderPaperclipWakePrompt(wake);
expect(isPaperclipExternalChatQuestionResponseTurn(wake)).toBe(false);
expect(prompt).not.toContain(
"## External chat answered-question contract",
);
}
});
it.each([false, true])(
"keeps routine prepared and waiting status out of native chat summaries (resumed: %s)",
(resumedSession) => {
for (const wake of [ordinaryExternalChatWake, externalQuestionWake]) {
const prompt = renderPaperclipWakePrompt(wake, { resumedSession });
expect(prompt).toContain(
"omit routine file-preparation, unconfirmed-delivery, and waiting-for-next-message status",
);
expect(prompt).toContain(
"end after the requested content or a neutral file label",
);
expect(prompt).toContain(
"Report a genuine failure or required user action plainly, without claiming a delivery that has not been confirmed.",
);
}
},
);
it.each([
[
"process_lost",
@ -1446,17 +2004,21 @@ describe("renderPaperclipWakePrompt", () => {
].join("\n"),
},
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [{
id: "stale-comment",
body: "The questions are still pending.",
authorType: "user",
}],
comments: [
{
id: "stale-comment",
body: "The questions are still pending.",
authorType: "user",
},
],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("## Answered questions");
expect(prompt).toContain("- What should the demo prove?: Minimal JSON API");
expect(prompt).toContain("Continue from these answers now; do not wait for another response.");
expect(prompt).toContain(
"Continue from these answers now; do not wait for another response.",
);
expect(prompt.indexOf("The questions are still pending.")).toBeLessThan(
prompt.indexOf("## Answered questions"),
);
@ -1895,7 +2457,9 @@ describe("renderPaperclipWakePrompt", () => {
});
expect(prompt).toContain("accepted-plan continuation");
expect(prompt).toContain("do not create a child merely because a plan was accepted");
expect(prompt).toContain(
"do not create a child merely because a plan was accepted",
);
expect(prompt).not.toContain("Update the plan only");
});

File diff suppressed because it is too large Load Diff

View File

@ -646,7 +646,7 @@ export type TranscriptEntry =
| { kind: "workspace_change"; ts: string; changeSetId: string; revision: number; source: "harness_reported" | "runner_verified"; complete: boolean; files: TranscriptWorkspaceChangeFile[]; totals: { files: number; additions: number | null; deletions: number | null }; patchArtifactRef: string | null }
| { kind: "workspace_file_reference"; ts: string; referenceId: string; source: "harness_reported" | "runner_verified"; path: string; displayName: string; mediaType: string | null; presentation: "document" | "code" | "image" | "generic"; line: number | null; preview: string | null; previewTruncated: boolean; contentDigest: string | null }
| { kind: "runtime_request"; ts: string; requestId: string; requestKind: "runtime" | "command_approval" | "file_approval" | "permission_approval" | "user_input" | "elicitation" | null; turnId: string | null; requestType: "permission" | "input"; status: "pending" | "resolved" | "expired" | "cancelled"; prompt: string; choices: Array<{ key: string; label: string }>; fields: Array<{ name: string; label: string; placeholder: string | null }>; questionSet?: PaperclipQuestionSet | null; resolvedAction?: string | null; response?: PaperclipQuestionResponse | null }
| { kind: "run_result"; ts: string; disposition: "done" | "blocked" | "needs_review" | "yielded"; summary: string; objectiveSatisfied: boolean | null; verification: TranscriptRunVerification[]; remainingWork: Array<{ description: string; blocksCompletion: boolean }>; blocker: { reasonCode: string; unblockAction: string; scope: "current_track" | "task_wide" } | null; artifacts: TranscriptRunArtifact[] }
| { kind: "run_result"; ts: string; disposition: "done" | "blocked" | "needs_review" | "yielded"; summary: string; objectiveSatisfied: boolean | null; verification: TranscriptRunVerification[]; remainingWork: Array<{ description: string; blocksCompletion: boolean }>; blocker: { reasonCode: string; unblockAction: string; scope: "current_track" | "task_wide" } | null; artifacts: TranscriptRunArtifact[]; acceptedResponseWake?: { runId: string; sourceEventId: string } }
| { kind: "run_terminal"; ts: string; turnState: "completed" | "failed" | "interrupted" | "cancelled"; runState: "succeeded" | "failed" | "cancelled"; disposition: "done" | "blocked" | "needs_review" | "yielded"; stopReason?: string };
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];

View File

@ -8,9 +8,9 @@ The skill/reference inventory and eval cases are the only normative behavior sou
## Baseline Counts
- Skill/reference headings: 152
- Skill/reference headings: 153
- Eval cases: 106 across 16 groups
- Total normative rows: 258
- Total normative rows: 259
- Legacy MCP aliases folded into normative rows: 42
| Eval group | Cases |
@ -44,31 +44,32 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:paperclip-skill:10 | optional_agent_tool | skills/paperclip/SKILL.md:10 |
| skill:skills/paperclip/SKILL.md:terminology:14 | optional_agent_tool | skills/paperclip/SKILL.md:14 |
| skill:skills/paperclip/SKILL.md:authentication:18 | control_plane_owned | skills/paperclip/SKILL.md:18 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:30 | optional_agent_tool | skills/paperclip/SKILL.md:30 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:101 | always_agent_tool | skills/paperclip/SKILL.md:101 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:148 | control_plane_owned | skills/paperclip/SKILL.md:148 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:158 | optional_agent_tool | skills/paperclip/SKILL.md:158 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:171 | always_agent_tool | skills/paperclip/SKILL.md:171 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:182 | control_plane_owned | skills/paperclip/SKILL.md:182 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:190 | control_plane_owned | skills/paperclip/SKILL.md:190 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:215 | optional_agent_tool | skills/paperclip/SKILL.md:215 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:236 | optional_agent_tool | skills/paperclip/SKILL.md:236 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:265 | optional_agent_tool | skills/paperclip/SKILL.md:265 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:369 | optional_agent_tool | skills/paperclip/SKILL.md:369 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:411 | optional_agent_tool | skills/paperclip/SKILL.md:411 |
| skill:skills/paperclip/SKILL.md:cases:421 | optional_agent_tool | skills/paperclip/SKILL.md:421 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:426 | optional_agent_tool | skills/paperclip/SKILL.md:426 |
| skill:skills/paperclip/SKILL.md:routines:437 | optional_agent_tool | skills/paperclip/SKILL.md:437 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:448 | optional_agent_tool | skills/paperclip/SKILL.md:448 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:455 | optional_agent_tool | skills/paperclip/SKILL.md:455 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:462 | optional_agent_tool | skills/paperclip/SKILL.md:462 |
| skill:skills/paperclip/SKILL.md:critical-rules:488 | optional_agent_tool | skills/paperclip/SKILL.md:488 |
| skill:skills/paperclip/SKILL.md:comment-style-required:512 | always_agent_tool | skills/paperclip/SKILL.md:512 |
| skill:skills/paperclip/SKILL.md:update:544 | optional_agent_tool | skills/paperclip/SKILL.md:544 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:554 | optional_agent_tool | skills/paperclip/SKILL.md:554 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:587 | optional_agent_tool | skills/paperclip/SKILL.md:587 |
| skill:skills/paperclip/SKILL.md:searching-issues:616 | optional_agent_tool | skills/paperclip/SKILL.md:616 |
| skill:skills/paperclip/SKILL.md:full-reference:626 | optional_agent_tool | skills/paperclip/SKILL.md:626 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:30 | control_plane_owned | skills/paperclip/SKILL.md:30 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:70 | optional_agent_tool | skills/paperclip/SKILL.md:70 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:142 | always_agent_tool | skills/paperclip/SKILL.md:142 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:190 | control_plane_owned | skills/paperclip/SKILL.md:190 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:200 | optional_agent_tool | skills/paperclip/SKILL.md:200 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:213 | always_agent_tool | skills/paperclip/SKILL.md:213 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:224 | control_plane_owned | skills/paperclip/SKILL.md:224 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:232 | control_plane_owned | skills/paperclip/SKILL.md:232 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:257 | optional_agent_tool | skills/paperclip/SKILL.md:257 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:278 | optional_agent_tool | skills/paperclip/SKILL.md:278 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:307 | optional_agent_tool | skills/paperclip/SKILL.md:307 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:411 | optional_agent_tool | skills/paperclip/SKILL.md:411 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:453 | optional_agent_tool | skills/paperclip/SKILL.md:453 |
| skill:skills/paperclip/SKILL.md:cases:463 | optional_agent_tool | skills/paperclip/SKILL.md:463 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
| skill:skills/paperclip/SKILL.md:routines:479 | optional_agent_tool | skills/paperclip/SKILL.md:479 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:490 | optional_agent_tool | skills/paperclip/SKILL.md:490 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:497 | optional_agent_tool | skills/paperclip/SKILL.md:497 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:504 | optional_agent_tool | skills/paperclip/SKILL.md:504 |
| skill:skills/paperclip/SKILL.md:critical-rules:530 | optional_agent_tool | skills/paperclip/SKILL.md:530 |
| skill:skills/paperclip/SKILL.md:comment-style-required:554 | always_agent_tool | skills/paperclip/SKILL.md:554 |
| skill:skills/paperclip/SKILL.md:update:586 | optional_agent_tool | skills/paperclip/SKILL.md:586 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:596 | optional_agent_tool | skills/paperclip/SKILL.md:596 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:629 | optional_agent_tool | skills/paperclip/SKILL.md:629 |
| skill:skills/paperclip/SKILL.md:searching-issues:658 | optional_agent_tool | skills/paperclip/SKILL.md:658 |
| skill:skills/paperclip/SKILL.md:full-reference:668 | optional_agent_tool | skills/paperclip/SKILL.md:668 |
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |

View File

@ -31,223 +31,232 @@
{
"id": "skill:skills/paperclip/SKILL.md:30",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L30:the-heartbeat-procedure",
"sourceAnchor": "skills/paperclip/SKILL.md#L30:server-verified-external-chat-turns",
"heading": "Server-Verified External Chat Turns",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:70",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L70:the-heartbeat-procedure",
"heading": "The Heartbeat Procedure",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:101",
"id": "skill:skills/paperclip/SKILL.md:142",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L101:generated-artifacts-and-work-products",
"sourceAnchor": "skills/paperclip/SKILL.md#L142:generated-artifacts-and-work-products",
"heading": "Generated Artifacts and Work Products",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "register_deliverable",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:148",
"id": "skill:skills/paperclip/SKILL.md:190",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L148:status-quick-guide",
"sourceAnchor": "skills/paperclip/SKILL.md#L190:status-quick-guide",
"heading": "Status Quick Guide",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:158",
"id": "skill:skills/paperclip/SKILL.md:200",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L158:monitors-and-watchers-say-only-what-you-actually-scheduled",
"sourceAnchor": "skills/paperclip/SKILL.md#L200:monitors-and-watchers-say-only-what-you-actually-scheduled",
"heading": "Monitors and Watchers (say only what you actually scheduled)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:171",
"id": "skill:skills/paperclip/SKILL.md:213",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L171:delegating-review-tasks",
"sourceAnchor": "skills/paperclip/SKILL.md#L213:delegating-review-tasks",
"heading": "Delegating review tasks",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:182",
"id": "skill:skills/paperclip/SKILL.md:224",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L182:managing-a-user-s-inbox",
"sourceAnchor": "skills/paperclip/SKILL.md#L224:managing-a-user-s-inbox",
"heading": "Managing A User's Inbox",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:190",
"id": "skill:skills/paperclip/SKILL.md:232",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L190:issue-dependencies-blockers",
"sourceAnchor": "skills/paperclip/SKILL.md#L232:issue-dependencies-blockers",
"heading": "Issue Dependencies (Blockers)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:215",
"id": "skill:skills/paperclip/SKILL.md:257",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L215:requesting-board-approval",
"sourceAnchor": "skills/paperclip/SKILL.md#L257:requesting-board-approval",
"heading": "Requesting Board Approval",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:236",
"id": "skill:skills/paperclip/SKILL.md:278",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L236:issue-thread-interactions",
"sourceAnchor": "skills/paperclip/SKILL.md#L278:issue-thread-interactions",
"heading": "Issue-Thread Interactions",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:265",
"id": "skill:skills/paperclip/SKILL.md:307",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L265:standalone-decisions",
"sourceAnchor": "skills/paperclip/SKILL.md#L307:standalone-decisions",
"heading": "Standalone Decisions",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:369",
"id": "skill:skills/paperclip/SKILL.md:411",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L369:mcp-tool-approval-gates",
"sourceAnchor": "skills/paperclip/SKILL.md#L411:mcp-tool-approval-gates",
"heading": "MCP Tool Approval Gates",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:411",
"id": "skill:skills/paperclip/SKILL.md:453",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L411:niche-workflow-pointers",
"sourceAnchor": "skills/paperclip/SKILL.md#L453:niche-workflow-pointers",
"heading": "Niche Workflow Pointers",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:421",
"id": "skill:skills/paperclip/SKILL.md:463",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L421:cases",
"sourceAnchor": "skills/paperclip/SKILL.md#L463:cases",
"heading": "Cases",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:426",
"id": "skill:skills/paperclip/SKILL.md:468",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L426:company-skills-workflow",
"sourceAnchor": "skills/paperclip/SKILL.md#L468:company-skills-workflow",
"heading": "Company Skills Workflow",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:437",
"id": "skill:skills/paperclip/SKILL.md:479",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L437:routines",
"sourceAnchor": "skills/paperclip/SKILL.md#L479:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:448",
"id": "skill:skills/paperclip/SKILL.md:490",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L448:issue-workspace-runtime-controls",
"sourceAnchor": "skills/paperclip/SKILL.md#L490:issue-workspace-runtime-controls",
"heading": "Issue Workspace Runtime Controls",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:455",
"id": "skill:skills/paperclip/SKILL.md:497",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L455:proposing-credentials-safely",
"sourceAnchor": "skills/paperclip/SKILL.md#L497:proposing-credentials-safely",
"heading": "Proposing Credentials Safely",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:462",
"id": "skill:skills/paperclip/SKILL.md:504",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L462:reading-granted-secrets",
"sourceAnchor": "skills/paperclip/SKILL.md#L504:reading-granted-secrets",
"heading": "Reading Granted Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:488",
"id": "skill:skills/paperclip/SKILL.md:530",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L488:critical-rules",
"sourceAnchor": "skills/paperclip/SKILL.md#L530:critical-rules",
"heading": "Critical Rules",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:512",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L512:comment-style-required",
"heading": "Comment Style (Required)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "report_progress",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:544",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L544:update",
"heading": "Update",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:554",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L554:planning-required-when-planning-requested",
"sourceAnchor": "skills/paperclip/SKILL.md#L554:comment-style-required",
"heading": "Comment Style (Required)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "report_progress",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:586",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L586:update",
"heading": "Update",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:596",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L596:planning-required-when-planning-requested",
"heading": "Planning (Required when planning requested)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "write_document",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:587",
"id": "skill:skills/paperclip/SKILL.md:629",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L587:key-endpoints-hot-routes",
"sourceAnchor": "skills/paperclip/SKILL.md#L629:key-endpoints-hot-routes",
"heading": "Key Endpoints (Hot Routes)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:616",
"id": "skill:skills/paperclip/SKILL.md:658",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L616:searching-issues",
"sourceAnchor": "skills/paperclip/SKILL.md#L658:searching-issues",
"heading": "Searching Issues",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:626",
"id": "skill:skills/paperclip/SKILL.md:668",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L626:full-reference",
"sourceAnchor": "skills/paperclip/SKILL.md#L668:full-reference",
"heading": "Full Reference",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",

View File

@ -2,9 +2,9 @@
Generated by `scripts/generate-capability-contract.mjs`; do not edit generated files.
- Skill/reference headings: 153
- Skill/reference headings: 154
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `6eefb67205ab07317bc8670ec7d39bce2f3aad38d8f376593ef0f61c18fb60c0`
- Deterministic content SHA-256: `7d89b580b41830403a625dc44644e5faf9b5eb83a27706bc2d624d9da464d331`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.

View File

@ -592,6 +592,14 @@ pub struct CodexProvider {
opencode_launch_profile: Option<OpenCodeLaunchProfile>,
completion_contract: Option<ProviderCompletionContract>,
permission_profile: &'static str,
exit_drain: Option<ProviderExitDrain>,
}
struct ProviderExitDrain {
process_generation: u64,
deadline: std::time::Instant,
timed_out: bool,
warning_emitted: bool,
}
// The controller accepts at most 32 process-scoped Git config entries and
@ -919,6 +927,7 @@ impl CodexProvider {
}
}),
permission_profile,
exit_drain: None,
};
let mut stage = ProviderStartupStage::Initialize;
let initialized_result = (|| -> Result<(), LocalRunnerError> {
@ -1907,7 +1916,83 @@ impl CodexProvider {
}))
}
fn exit_event(&self, exit: ProcessExitFact, drain_timed_out: bool) -> CodexProviderEvent {
// A recorded terminal remains authoritative even if its reusable
// process later fails. Only a completion from this exact generation
// can reconcile that exit; fresh or ambiguous work revokes the old
// reconciliation, and an incomplete stdout drain never certifies it.
let completed_turn_authoritative = !self.quarantined
&& self.completed_turn_authority.is_some()
&& self.active_provider_turn_id.is_none();
let completed_turn_observed_by_process = !self.quarantined
&& self
.completed_turn_authority
.as_ref()
.is_some_and(|authority| authority.process_generation == self.process_generation);
CodexProviderEvent::Exited {
exit_code: exit.exit_code,
success: !self.quarantined
&& !drain_timed_out
&& exit.success
&& !self.ambiguous_turn_start_pending
&& (self.expected_shutdown || completed_turn_authoritative),
completed_turn_authoritative,
completed_turn_observed_by_process,
completion_reconciles_exit: !drain_timed_out
&& completed_turn_authoritative
&& completed_turn_observed_by_process
&& self.completion_reconciliation_pending,
process_generation: self.process_generation,
completed_turn_process_generation: self
.completed_turn_authority
.as_ref()
.filter(|_| !self.quarantined)
.map(|authority| authority.process_generation),
}
}
pub fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
if self.process.stdout_failed() {
return Err(LocalRunnerError::invalid(
"Codex stdout closed without a valid reader EOF",
));
}
// A leader may exit while its reader still owns queued frames, or while
// a descendant retains the pipe. Observe exit even during output floods
// and bound only this post-exit drain by the existing shutdown grace.
if let Some(exit) = self.process.try_wait()? {
if self
.exit_drain
.as_ref()
.is_none_or(|drain| drain.process_generation != self.process_generation)
{
self.exit_drain = Some(ProviderExitDrain {
process_generation: self.process_generation,
deadline: std::time::Instant::now() + self.process.shutdown_grace(),
timed_out: false,
warning_emitted: false,
});
}
let drain = self
.exit_drain
.as_mut()
.expect("observed exit has a drain bound");
drain.timed_out |=
!self.process.stdout_drained() && std::time::Instant::now() >= drain.deadline;
if drain.timed_out {
if !drain.warning_emitted {
drain.warning_emitted = true;
return Ok(Some(CodexProviderEvent::Notification {
method: "configWarning".to_owned(),
params: json!({
"code": "provider_stdout_drain_timeout",
"message": "Provider exited before stdout was fully drained within shutdown grace; the session cannot be safely reused.",
}),
}));
}
return Ok(Some(self.exit_event(exit, true)));
}
}
if self.quarantined {
// Never interpret provider-originated requests after fail-closed
// quarantine. Drain output only so process termination cannot
@ -1919,6 +2004,9 @@ impl CodexProvider {
{
return Ok(None);
}
if !self.process.stdout_drained() {
return Ok(None);
}
return Ok(self
.process
.try_wait()?
@ -1944,43 +2032,11 @@ impl CodexProvider {
} else {
let Some(line) = self.process.receive_stdout_line(Duration::from_millis(1))? else {
let exit = self.process.try_wait()?;
if !self.process.stdout_drained() {
return Ok(None);
}
return if let Some(exit) = exit {
let completed_turn_authoritative = self.completed_turn_authority.is_some()
&& self.active_provider_turn_id.is_none();
let completed_turn_observed_by_process = self
.completed_turn_authority
.as_ref()
.is_some_and(|authority| {
authority.process_generation == self.process_generation
});
// A durable terminal remains the run outcome, but it only
// reconciles the process generation that produced it. A
// later recovered provider can fail independently while
// leaving the already-recorded turn result intact.
let completion_reconciles_exit = completed_turn_authoritative
&& completed_turn_observed_by_process
&& self.completion_reconciliation_pending;
Ok(Some(CodexProviderEvent::Exited {
exit_code: exit.exit_code,
// A clean idle exit after a terminal is healthy. A
// nonzero exit still makes the provider unavailable,
// but the durable terminal reconciles it instead of
// allowing the session to fail retroactively. Fresh
// turn work explicitly revokes the prior authority.
// An unresolved start may already have created fresh
// work, so even a clean exit must fail that session.
success: exit.success
&& !self.ambiguous_turn_start_pending
&& (self.expected_shutdown || completed_turn_authoritative),
completed_turn_authoritative,
completed_turn_observed_by_process,
completion_reconciles_exit,
process_generation: self.process_generation,
completed_turn_process_generation: self
.completed_turn_authority
.as_ref()
.map(|authority| authority.process_generation),
}))
Ok(Some(self.exit_event(exit, false)))
} else {
Ok(None)
};
@ -3699,6 +3755,398 @@ fn codex_question_response(
mod tests {
use super::*;
#[cfg(unix)]
fn completion_tail_provider() -> CodexProvider {
completion_tail_provider_with_stdout_flood(false)
}
#[cfg(unix)]
fn completion_tail_provider_with_stdout_flood(flood: bool) -> CodexProvider {
// A real JSON-RPC child writes the terminal before exiting. The
// per-instance receiver proxy below controls reader delivery only.
let script = r#"
turn=0
while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{}}' ;;
*'"method":"thread/start"'*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"reader-tail-thread"}}}' ;;
*'"method":"turn/start"'*)
turn=$((turn + 1))
if [ "$turn" = 1 ]; then
printf '%s\n' '{"id":3,"result":{"turn":{"id":"reader-tail-1"}}}'
printf '%s\n' '{"method":"turn/completed","params":{"turn":{"id":"reader-tail-1","status":"completed"}}}'
else
printf '%s\n' '{"id":4,"error":{}}'
printf '%s\n' '{"method":"turn/started","params":{"turn":{"id":"reader-tail-2"}}}'
printf '%s\n' '{"method":"turn/completed","params":{"turn":{"id":"reader-tail-2","status":"completed"}}}'
if __FLOOD_STDOUT__; then
(while :; do printf '%s\n' '{"method":"configWarning","params":{"message":"fixture output still flowing"}}'; done) &
fi
exit 1
fi ;;
esac
done
"#;
let config = CodexProviderConfig {
provider: "codex".to_owned(),
driver: "codex_app_server".to_owned(),
provider_version: "reader-tail-fixture".to_owned(),
command: PathBuf::from("/bin/sh"),
args: vec![
"-c".to_owned(),
script.replace("__FLOOD_STDOUT__", if flood { "true" } else { "false" }),
],
cwd: std::env::current_dir()
.unwrap()
.to_string_lossy()
.into_owned(),
model: None,
provider_session_id: None,
instructions: "Test only.".to_owned(),
approval_policy: "never".to_owned(),
externally_sandboxed: false,
};
let mut provider = CodexProvider::start(&config, None).unwrap();
provider.start_turn("First turn", &config.cwd).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
assert!(std::time::Instant::now() < deadline);
if matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed")
{
break;
}
}
provider
}
#[cfg(unix)]
struct HeldTerminalReader {
release: Option<mpsc::Sender<()>>,
stop: Arc<std::sync::atomic::AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
#[cfg(unix)]
impl Drop for HeldTerminalReader {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
if let Some(release) = self.release.take() {
let _ = release.send(());
}
if let Some(worker) = self.worker.take() {
worker.join().unwrap();
}
}
}
#[cfg(unix)]
fn hold_reader(
provider: &mut CodexProvider,
boundary: &'static str,
) -> (HeldTerminalReader, mpsc::Receiver<()>) {
// Test-only proxy output is unbounded so cleanup never joins a worker
// blocked on a full queue after the consuming assertion has failed.
let (sender, output) = mpsc::channel();
let source = provider.process.replace_output_receiver_for_test(output);
let (captured, ready) = mpsc::channel();
let (release, gate) = mpsc::channel();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let worker = thread::spawn(move || {
while !worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
let event = match source.recv_timeout(Duration::from_millis(10)) {
Ok(event) => event,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
let hold = match &event {
ProcessOutput::Stdout(line) => boundary != "EOF" && line.contains(boundary),
ProcessOutput::StdoutClosed => boundary == "EOF",
_ => false,
};
if hold {
if captured.send(()).is_err() {
break;
}
let _ = gate.recv();
}
if sender.send(event).is_err() {
break;
}
}
});
(
HeldTerminalReader {
release: Some(release),
stop,
worker: Some(worker),
},
ready,
)
}
#[test]
#[cfg(unix)]
fn exited_child_waits_for_held_actual_terminal_reader_before_certifying_exit() {
let mut provider = completion_tail_provider();
let (mut reader, ready) = hold_reader(&mut provider, "turn/completed");
let cwd = provider.config.cwd.clone();
provider
.start_turn("Replacement", &cwd)
.expect_err("malformed response remains ambiguous");
ready
.recv_timeout(Duration::from_secs(5))
.expect("actual replacement terminal reached reader proxy");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while provider.process.try_wait().unwrap().is_none() {
assert!(std::time::Instant::now() < deadline);
thread::yield_now();
}
assert!(
matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/started")
);
assert!(
provider.poll().unwrap().is_none(),
"exited leader does not prove its held stdout tail was drained"
);
reader.release.take().unwrap().send(()).unwrap();
let mut completed = false;
loop {
assert!(std::time::Instant::now() < deadline);
match provider.poll().unwrap() {
Some(CodexProviderEvent::Notification { method, params })
if method == "turn/completed" =>
{
assert_eq!(params["turn"]["id"], "reader-tail-2");
completed = true;
}
Some(CodexProviderEvent::Exited {
success,
completed_turn_authoritative,
completion_reconciles_exit,
..
}) => {
assert!(completed);
assert!(!success);
assert!(completed_turn_authoritative);
assert!(completion_reconciles_exit);
break;
}
_ => {}
}
}
}
#[test]
#[cfg(unix)]
fn exited_child_reader_timeout_preserves_only_observed_turn_authority() {
for boundary in ["turn/started", "turn/completed", "EOF"] {
let mut provider = completion_tail_provider();
let (mut reader, ready) = hold_reader(&mut provider, boundary);
let cwd = provider.config.cwd.clone();
provider
.start_turn("Replacement", &cwd)
.expect_err("actual malformed response");
ready
.recv_timeout(Duration::from_secs(5))
.expect("actual reader boundary held");
let wait_deadline = std::time::Instant::now() + Duration::from_secs(5);
while provider.process.try_wait().unwrap().is_none() {
assert!(std::time::Instant::now() < wait_deadline);
thread::yield_now();
}
let started_at = std::time::Instant::now();
let mut completed = 0;
let mut notices = 0;
loop {
assert!(
started_at.elapsed() < Duration::from_secs(5),
"drain remains bounded for {boundary}"
);
match provider.poll().unwrap() {
Some(CodexProviderEvent::Notification { method, params })
if method == "turn/completed" =>
{
assert_eq!(params["turn"]["id"], "reader-tail-2");
completed += 1;
}
Some(CodexProviderEvent::Notification { method, params })
if method == "configWarning" =>
{
assert_eq!(params["code"], "provider_stdout_drain_timeout");
assert_eq!(
crate::provider_events::normalize_codex_notification(&method, &params)
[0]
.event_type,
"provider.notice.recorded"
);
notices += 1;
}
Some(CodexProviderEvent::Exited {
success,
completed_turn_authoritative,
completed_turn_observed_by_process,
completion_reconciles_exit,
process_generation,
completed_turn_process_generation,
..
}) => {
assert!(!success);
assert!(!completion_reconciles_exit);
assert_eq!(completed_turn_authoritative, boundary != "turn/completed");
assert_eq!(
completed_turn_observed_by_process,
boundary != "turn/completed"
);
assert_eq!(process_generation, 1);
assert_eq!(
completed_turn_process_generation,
(boundary != "turn/completed").then_some(1)
);
break;
}
_ => {}
}
}
assert!(started_at.elapsed() >= provider.process.shutdown_grace());
assert_eq!(notices, 1);
assert_eq!(completed, usize::from(boundary == "EOF"));
let expected_id = match boundary {
"turn/started" => Some("reader-tail-1"),
"EOF" => Some("reader-tail-2"),
_ => None,
};
assert_eq!(
provider
.completed_turn_authority
.as_ref()
.map(|authority| authority.provider_turn_id.as_str()),
expected_id
);
reader.release.take().unwrap().send(()).unwrap();
assert!(
matches!(
provider.poll().unwrap(),
Some(CodexProviderEvent::Exited {
success: false,
completion_reconciles_exit: false,
..
})
),
"late tail must not revive a timed-out session"
);
assert_eq!(
provider
.completed_turn_authority
.as_ref()
.map(|authority| authority.provider_turn_id.as_str()),
expected_id
);
}
}
#[test]
#[cfg(unix)]
fn exited_child_reader_deadline_precedes_buffered_output_and_is_generation_scoped() {
let mut provider = completion_tail_provider();
let (mut reader, ready) = hold_reader(&mut provider, "turn/completed");
let cwd = provider.config.cwd.clone();
provider.start_turn("Replacement", &cwd).unwrap_err();
ready.recv_timeout(Duration::from_secs(5)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while provider.process.try_wait().unwrap().is_none() {
assert!(std::time::Instant::now() < deadline);
thread::yield_now();
}
// A buffered real started frame must not bypass an already-expired
// current-generation drain bound; no sleeps or global clock changes.
provider.exit_drain = Some(ProviderExitDrain {
process_generation: provider.process_generation,
deadline: std::time::Instant::now() - Duration::from_secs(1),
timed_out: false,
warning_emitted: false,
});
assert!(
matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, params }) if method == "configWarning" && params["code"] == "provider_stdout_drain_timeout")
);
// A stale generation's timeout is not inherited by a new owned epoch.
provider.exit_drain.as_mut().unwrap().process_generation = 0;
assert!(
matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/started")
);
assert!(!provider.exit_drain.as_ref().unwrap().timed_out);
reader.release.take().unwrap().send(()).unwrap();
}
#[test]
#[cfg(unix)]
fn exited_child_reader_deadline_bounds_continuous_descendant_stdout() {
let mut provider = completion_tail_provider_with_stdout_flood(true);
let cwd = provider.config.cwd.clone();
provider.start_turn("Replacement", &cwd).unwrap_err();
let started_at = std::time::Instant::now();
let mut flowing = 0;
let mut timeout_notices = 0;
let mut completed = 0;
loop {
assert!(
started_at.elapsed() < Duration::from_secs(5),
"flowing stdout cannot extend the exit drain indefinitely"
);
match provider.poll().unwrap() {
Some(CodexProviderEvent::Notification { method, params })
if method == "configWarning" =>
{
if params["code"] == "provider_stdout_drain_timeout" {
timeout_notices += 1;
} else {
assert_eq!(params["message"], "fixture output still flowing");
flowing += 1;
}
}
Some(CodexProviderEvent::Notification { method, params })
if method == "turn/completed" =>
{
assert_eq!(params["turn"]["id"], "reader-tail-2");
completed += 1;
}
Some(CodexProviderEvent::Exited {
success,
completed_turn_authoritative,
completion_reconciles_exit,
..
}) => {
assert!(!success);
assert!(completed_turn_authoritative);
assert!(!completion_reconciles_exit);
break;
}
_ => {}
}
}
assert!(started_at.elapsed() >= provider.process.shutdown_grace());
assert!(flowing > 10);
assert_eq!(timeout_notices, 1);
assert_eq!(completed, 1);
assert_eq!(
provider
.completed_turn_authority
.as_ref()
.unwrap()
.provider_turn_id,
"reader-tail-2"
);
provider.shutdown().unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !provider.process.stdout_drained() {
assert!(
std::time::Instant::now() < deadline,
"owned descendant writer must be retired"
);
let _ = provider.process.recv_timeout(Duration::from_millis(1));
}
}
#[test]
#[cfg(unix)]
fn startup_observer_failure_reaps_the_exact_child_before_any_initialization_rpc() {

View File

@ -1,3 +1,4 @@
use std::cell::Cell;
use std::collections::VecDeque;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write};
@ -633,6 +634,8 @@ pub struct SupervisedProcess {
child: Child,
stdin: Option<ChildStdin>,
output: Receiver<ProcessOutput>,
stdout_closed: Cell<bool>,
stdout_failed: Cell<bool>,
process_group_id: u32,
shutdown_grace: Duration,
finished: bool,
@ -641,6 +644,14 @@ pub struct SupervisedProcess {
}
impl SupervisedProcess {
#[cfg(test)]
pub(crate) fn replace_output_receiver_for_test(
&mut self,
output: Receiver<ProcessOutput>,
) -> Receiver<ProcessOutput> {
std::mem::replace(&mut self.output, output)
}
pub fn spawn(
program: &Path,
args: &[String],
@ -806,6 +817,8 @@ impl SupervisedProcess {
child,
stdin: Some(stdin),
output,
stdout_closed: Cell::new(false),
stdout_failed: Cell::new(false),
process_group_id,
shutdown_grace,
finished: false,
@ -842,7 +855,35 @@ impl SupervisedProcess {
&self,
timeout: Duration,
) -> Result<ProcessOutput, RecvTimeoutError> {
self.output.recv_timeout(timeout)
let result = self.output.recv_timeout(timeout);
match &result {
Ok(output) => self.observe_output(output),
Err(RecvTimeoutError::Disconnected) if !self.stdout_closed.get() => {
self.stdout_failed.set(true);
}
_ => {}
}
result
}
fn observe_output(&self, output: &ProcessOutput) {
match output {
ProcessOutput::StdoutClosed => self.stdout_closed.set(true),
ProcessOutput::StdoutError(_) => self.stdout_failed.set(true),
_ => {}
}
}
pub(crate) fn stdout_drained(&self) -> bool {
self.stdout_closed.get() && !self.stdout_failed.get()
}
pub(crate) fn stdout_failed(&self) -> bool {
self.stdout_failed.get()
}
pub(crate) fn shutdown_grace(&self) -> Duration {
self.shutdown_grace
}
pub fn receive_stdout_line(
@ -873,7 +914,15 @@ impl SupervisedProcess {
}
pub(crate) fn try_recv(&self) -> Result<ProcessOutput, mpsc::TryRecvError> {
self.output.try_recv()
let result = self.output.try_recv();
match &result {
Ok(output) => self.observe_output(output),
Err(mpsc::TryRecvError::Disconnected) if !self.stdout_closed.get() => {
self.stdout_failed.set(true);
}
_ => {}
}
result
}
pub fn try_wait(&mut self) -> Result<Option<ProcessExitFact>, LocalRunnerError> {
@ -983,6 +1032,72 @@ mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
#[cfg(unix)]
fn stdout_eof_is_sticky_across_consumers_while_stderr_remains_open() {
for use_try_recv in [false, true] {
let mut process = SupervisedProcess::spawn(
Path::new("/bin/sh"),
&[
"-c".to_owned(),
"printf 'tail\\n'; exec 1>&-; read -r finish; printf 'stderr-tail\\n' >&2"
.to_owned(),
],
Duration::from_secs(2),
1024,
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut saw_tail = false;
while !process.stdout_drained() {
assert!(Instant::now() < deadline);
let output = if use_try_recv {
process.try_recv().ok()
} else {
process.recv_timeout(Duration::from_millis(1)).ok()
};
if let Some(ProcessOutput::Stdout(line)) = output {
assert_eq!(line, "tail");
saw_tail = true;
}
}
assert!(saw_tail);
assert!(
process.try_wait().unwrap().is_none(),
"stderr/child are still live after stdout EOF"
);
assert_eq!(
process
.receive_stdout_line(Duration::from_millis(1))
.unwrap(),
None
);
assert!(process.stdout_drained());
process.send(&serde_json::json!({"finish": true})).unwrap();
process.wait().unwrap();
assert!(process.stdout_drained());
}
}
#[test]
#[cfg(unix)]
fn stdout_read_error_never_becomes_successful_drain() {
let mut process = SupervisedProcess::spawn(
Path::new("/bin/sh"),
&["-c".to_owned(), "printf 'oversized-frame\\n'".to_owned()],
Duration::from_secs(2),
4,
)
.unwrap();
assert!(process.receive_stdout_line(Duration::from_secs(5)).is_err());
assert!(process.stdout_failed());
assert!(!process.stdout_drained());
process.wait().unwrap();
while process.try_recv().is_ok() {}
assert!(process.stdout_failed());
assert!(!process.stdout_drained());
}
fn verified_artifact(path: &Path, bytes: &[u8]) -> VerifiedProcessArtifact {
fs::write(path, bytes).unwrap();
let digest = format!("sha256:{:x}", Sha256::digest(bytes));

View File

@ -25,7 +25,7 @@ function row(id, group = "hb") {
function validInventories() {
const evaluations = Array.from({ length: 106 }, (_, index) => row(`eval-${index}`, capabilityGroups[index % capabilityGroups.length]));
const aliases = Array.from({ length: 41 }, (_, index) => ({
const aliases = Array.from({ length: 42 }, (_, index) => ({
id: `mcp:tool-${index}`,
name: `tool-${index}`,
sourceAnchor: `source:${index + 1}`,
@ -42,7 +42,7 @@ function validInventories() {
schemaVersion: 2,
inventoryRole: "normative",
generatedFrom: ["skills/paperclip/SKILL.md"],
rows: Array.from({ length: 152 }, (_, index) => row(`capability-${index}`)),
rows: Array.from({ length: 153 }, (_, index) => row(`capability-${index}`)),
},
evaluations: {
schemaVersion: 2,

View File

@ -96,7 +96,7 @@ function slug(value) {
function classifySkillHeading(heading) {
const value = heading.toLowerCase();
if (/(authentication|identity|scope|checkout|lock|retry|dedupe|budget|wake|inbox|pick work|status quick guide|error handling)/.test(value)) {
if (/(authentication|identity|scope|checkout|lock|retry|dedupe|budget|wake|inbox|pick work|status quick guide|error handling|server-verified external chat)/.test(value)) {
return "control_plane_owned";
}
if (/(artifact|work product|comment|document|confirmation|question|approval follow-up|block|review|final disposition|issue lifecycle)/.test(value)) {
@ -247,7 +247,7 @@ export async function buildMcpInventory(repoRoot) {
export function validateInventories(inventories) {
const errors = [];
const expectedCounts = { capabilities: 152, evaluations: 106, legacyMcpAliases: 42 };
const expectedCounts = { capabilities: 153, evaluations: 106, legacyMcpAliases: 42 };
const normativeNames = ["capabilities", "evaluations"];
const normativeRows = new Map();
const globalNormativeIds = new Set();

View File

@ -59,12 +59,12 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:30",
"id": "skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:30",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:30",
"title": "The Heartbeat Procedure",
"expectedSemantics": "Skill guidance headed “The Heartbeat Procedure”.",
"primaryDisposition": "optional_agent_tool",
"title": "Server-Verified External Chat Turns",
"expectedSemantics": "Skill guidance headed “Server-Verified External Chat Turns”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
@ -74,9 +74,24 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:101",
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:70",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:101",
"sourceAnchor": "skills/paperclip/SKILL.md:70",
"title": "The Heartbeat Procedure",
"expectedSemantics": "Skill guidance headed “The Heartbeat Procedure”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:70"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:142",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:142",
"title": "Generated Artifacts and Work Products",
"expectedSemantics": "Skill guidance headed “Generated Artifacts and Work Products”.",
"primaryDisposition": "always_agent_tool",
@ -85,75 +100,15 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:101"
"skill:skills/paperclip/SKILL.md:142"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:148",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:148",
"title": "Status Quick Guide",
"expectedSemantics": "Skill guidance headed “Status Quick Guide”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:148"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:158",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:158",
"title": "Monitors and Watchers (say only what you actually scheduled)",
"expectedSemantics": "Skill guidance headed “Monitors and Watchers (say only what you actually scheduled)”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:158"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:171",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:171",
"title": "Delegating review tasks",
"expectedSemantics": "Skill guidance headed “Delegating review tasks”.",
"primaryDisposition": "always_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:171"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:182",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:182",
"title": "Managing A User's Inbox",
"expectedSemantics": "Skill guidance headed “Managing A User's Inbox”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:182"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:190",
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:190",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:190",
"title": "Issue Dependencies (Blockers)",
"expectedSemantics": "Skill guidance headed “Issue Dependencies (Blockers)”.",
"title": "Status Quick Guide",
"expectedSemantics": "Skill guidance headed “Status Quick Guide”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
@ -164,9 +119,69 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:215",
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:200",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:215",
"sourceAnchor": "skills/paperclip/SKILL.md:200",
"title": "Monitors and Watchers (say only what you actually scheduled)",
"expectedSemantics": "Skill guidance headed “Monitors and Watchers (say only what you actually scheduled)”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:200"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:213",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:213",
"title": "Delegating review tasks",
"expectedSemantics": "Skill guidance headed “Delegating review tasks”.",
"primaryDisposition": "always_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:213"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:224",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:224",
"title": "Managing A User's Inbox",
"expectedSemantics": "Skill guidance headed “Managing A User's Inbox”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:224"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:232",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:232",
"title": "Issue Dependencies (Blockers)",
"expectedSemantics": "Skill guidance headed “Issue Dependencies (Blockers)”.",
"primaryDisposition": "control_plane_owned",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:232"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:257",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:257",
"title": "Requesting Board Approval",
"expectedSemantics": "Skill guidance headed “Requesting Board Approval”.",
"primaryDisposition": "optional_agent_tool",
@ -175,13 +190,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:215"
"skill:skills/paperclip/SKILL.md:257"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:236",
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:278",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:236",
"sourceAnchor": "skills/paperclip/SKILL.md:278",
"title": "Issue-Thread Interactions",
"expectedSemantics": "Skill guidance headed “Issue-Thread Interactions”.",
"primaryDisposition": "optional_agent_tool",
@ -190,13 +205,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:236"
"skill:skills/paperclip/SKILL.md:278"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:265",
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:307",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:265",
"sourceAnchor": "skills/paperclip/SKILL.md:307",
"title": "Standalone Decisions",
"expectedSemantics": "Skill guidance headed “Standalone Decisions”.",
"primaryDisposition": "optional_agent_tool",
@ -205,30 +220,15 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:265"
"skill:skills/paperclip/SKILL.md:307"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:369",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:369",
"title": "MCP Tool Approval Gates",
"expectedSemantics": "Skill guidance headed “MCP Tool Approval Gates”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:369"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:411",
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:411",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:411",
"title": "Niche Workflow Pointers",
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
"title": "MCP Tool Approval Gates",
"expectedSemantics": "Skill guidance headed “MCP Tool Approval Gates”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@ -239,9 +239,24 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:cases:421",
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:453",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:421",
"sourceAnchor": "skills/paperclip/SKILL.md:453",
"title": "Niche Workflow Pointers",
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:453"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:cases:463",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:463",
"title": "Cases",
"expectedSemantics": "Skill guidance headed “Cases”.",
"primaryDisposition": "optional_agent_tool",
@ -250,13 +265,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:421"
"skill:skills/paperclip/SKILL.md:463"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:426",
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:468",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:426",
"sourceAnchor": "skills/paperclip/SKILL.md:468",
"title": "Company Skills Workflow",
"expectedSemantics": "Skill guidance headed “Company Skills Workflow”.",
"primaryDisposition": "optional_agent_tool",
@ -265,13 +280,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:426"
"skill:skills/paperclip/SKILL.md:468"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:routines:437",
"id": "skill:skills/paperclip/SKILL.md:routines:479",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:437",
"sourceAnchor": "skills/paperclip/SKILL.md:479",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@ -280,13 +295,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:437"
"skill:skills/paperclip/SKILL.md:479"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:448",
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:490",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:448",
"sourceAnchor": "skills/paperclip/SKILL.md:490",
"title": "Issue Workspace Runtime Controls",
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
"primaryDisposition": "optional_agent_tool",
@ -295,13 +310,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:448"
"skill:skills/paperclip/SKILL.md:490"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:455",
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:497",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:455",
"sourceAnchor": "skills/paperclip/SKILL.md:497",
"title": "Proposing Credentials Safely",
"expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.",
"primaryDisposition": "optional_agent_tool",
@ -310,13 +325,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:455"
"skill:skills/paperclip/SKILL.md:497"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:462",
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:504",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:462",
"sourceAnchor": "skills/paperclip/SKILL.md:504",
"title": "Reading Granted Secrets",
"expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.",
"primaryDisposition": "optional_agent_tool",
@ -325,13 +340,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:462"
"skill:skills/paperclip/SKILL.md:504"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:critical-rules:488",
"id": "skill:skills/paperclip/SKILL.md:critical-rules:530",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:488",
"sourceAnchor": "skills/paperclip/SKILL.md:530",
"title": "Critical Rules",
"expectedSemantics": "Skill guidance headed “Critical Rules”.",
"primaryDisposition": "optional_agent_tool",
@ -340,13 +355,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:488"
"skill:skills/paperclip/SKILL.md:530"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:512",
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:554",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:512",
"sourceAnchor": "skills/paperclip/SKILL.md:554",
"title": "Comment Style (Required)",
"expectedSemantics": "Skill guidance headed “Comment Style (Required)”.",
"primaryDisposition": "always_agent_tool",
@ -354,44 +369,44 @@
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:512"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:update:544",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:544",
"title": "Update",
"expectedSemantics": "Skill guidance headed “Update”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:544"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:554",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:554",
"title": "Planning (Required when planning requested)",
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:554"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:587",
"id": "skill:skills/paperclip/SKILL.md:update:586",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:587",
"sourceAnchor": "skills/paperclip/SKILL.md:586",
"title": "Update",
"expectedSemantics": "Skill guidance headed “Update”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:586"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:596",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:596",
"title": "Planning (Required when planning requested)",
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:596"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:629",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:629",
"title": "Key Endpoints (Hot Routes)",
"expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.",
"primaryDisposition": "optional_agent_tool",
@ -400,13 +415,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:587"
"skill:skills/paperclip/SKILL.md:629"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:searching-issues:616",
"id": "skill:skills/paperclip/SKILL.md:searching-issues:658",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:616",
"sourceAnchor": "skills/paperclip/SKILL.md:658",
"title": "Searching Issues",
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
"primaryDisposition": "optional_agent_tool",
@ -415,13 +430,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:616"
"skill:skills/paperclip/SKILL.md:658"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:full-reference:626",
"id": "skill:skills/paperclip/SKILL.md:full-reference:668",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:626",
"sourceAnchor": "skills/paperclip/SKILL.md:668",
"title": "Full Reference",
"expectedSemantics": "Skill guidance headed “Full Reference”.",
"primaryDisposition": "optional_agent_tool",
@ -430,7 +445,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:626"
"skill:skills/paperclip/SKILL.md:668"
]
},
{

View File

@ -94,11 +94,20 @@
"replay": { "attempts": 3, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
},
{
"id": "false-task-wide-blocker",
"id": "current-track-blocker-without-alternative",
"mode": "native",
"covers": { "decisionRows": ["SD-07"], "terminalRows": [], "attentionRows": ["ATT-05"], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
"tags": ["false_blocker", "excessive_human_request", "continuation"],
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "blocked", "nativeFinalization": "present", "completionState": "alternate_track_runnable", "trigger": "runner_finalizer" },
"covers": { "decisionRows": ["SD-07"], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
"tags": ["blocked_request", "no_inferred_alternative", "atomic_liveness"],
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "blocked", "nativeFinalization": "present", "completionState": "current_track_without_alternative", "trigger": "runner_finalizer" },
"expected": { "runStatus": "succeeded", "statusAction": "blocked", "reasonCode": "current_track_blocker_waiting", "requiredEffects": ["bind_blocker"], "forbiddenEffects": ["enqueue_continuation", "notify_owner", "create_interaction"], "livePathKind": "blocker", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
},
{
"id": "authorized-alternate-track-attention",
"mode": "native",
"covers": { "decisionRows": [], "terminalRows": [], "attentionRows": ["ATT-05"], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
"tags": ["explicit_attention", "continuation"],
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "blocked", "nativeFinalization": "present", "completionState": "alternate_track_runnable", "trigger": "attention_candidate" },
"expected": { "runStatus": "succeeded", "statusAction": "in_progress", "reasonCode": "turn_waiting_other_track_live", "requiredEffects": ["enqueue_continuation"], "forbiddenEffects": ["bind_blocker", "create_interaction"], "livePathKind": "continuation", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 1, "maxNotificationCount": 0 },
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
},

View File

@ -1083,6 +1083,212 @@ function corruptSemanticInputDigest(
return event;
}
it.each(["committed", "rejected"] as const)(
"holds explicit maintenance retirement through a closed connection's queued processing (%s)",
async (suffixOutcome) => {
const root = mkdtempSync(resolve(tmpdir(), "runner-processing-drain-"));
const releases: Array<() => void> = [];
const gates = [0, 1].map(
() => new Promise<void>((release) => releases.push(release)),
);
const onCommittedEvent = vi.fn(async (event: { sourceSeq: number }) => {
await gates[event.sourceSeq - 1];
if (event.sourceSeq === 2 && suffixOutcome === "rejected")
throw new Error("retained suffix commit rejected");
});
const core = new DurablePrpControlPlane({
stateDirectory: root,
identity,
expectedRunnerVersion,
expectedRunnerDigest,
onCommittedEvent,
});
let admittedFrames = 0;
const attachWire = core.attachWireConnection.bind(core);
const wireSpy = vi
.spyOn(core, "attachWireConnection")
.mockImplementation((wire) =>
attachWire({
onJson: (listener) =>
wire.onJson((value) => {
admittedFrames += 1;
listener(value);
}),
onClose: wire.onClose.bind(wire),
close: wire.close.bind(wire),
sendJson: wire.sendJson.bind(wire),
}),
);
let client: AuthenticatedClient | null = null;
let retirement: Promise<void> | undefined;
const retired = vi.fn();
try {
await core.start();
client = (await authenticate(core, core.issueBootstrapTicket()))!;
const beforeFrames = admittedFrames;
for (const sourceSeq of [1, 2]) {
const envelope = semanticInputEvent(sourceSeq);
envelope.payload = {
...(envelope.payload as Record<string, unknown>),
eventType: "item.delta",
priority: 2,
payload: { delta: `retained-${sourceSeq}` },
};
expect(validatePrpEvent(envelope.payload)).toMatchObject({ ok: true });
sendSecure(client, envelope);
}
await vi.waitFor(() => {
expect(admittedFrames).toBe(beforeFrames + 2);
expect(onCommittedEvent).toHaveBeenCalledTimes(1);
});
// Both frames have entered the real authenticated processing chain;
// socket shutdown does not settle either of their durable callbacks.
await core.stop();
expect(core.activeRunnerConnectionCount()).toBe(0);
retirement = core
.drainPendingConnectionProcessing()
.then(() => retired());
await new Promise<void>((resolveTurn) => setImmediate(resolveTurn));
expect(retired).not.toHaveBeenCalled();
expect(core.store.state.ackedSourceSeq).toBe(0);
releases[0]!();
await vi.waitFor(() => expect(onCommittedEvent).toHaveBeenCalledTimes(2));
expect(core.store.state.ackedSourceSeq).toBe(1);
expect(retired).not.toHaveBeenCalled();
releases[1]!();
await retirement;
expect(retired).toHaveBeenCalledOnce();
const stored = JSON.parse(
readFileSync(resolve(root, "control-plane-state.json"), "utf8"),
);
expect(stored.ackedSourceSeq).toBe(suffixOutcome === "committed" ? 2 : 1);
expect(
stored.committedEvents.map(
(event: { sourceSeq: number }) => event.sourceSeq,
),
).toEqual(suffixOutcome === "committed" ? [1, 2] : [1]);
expect(stored.commands).toEqual([]);
} finally {
releases.forEach((release) => release());
client?.socket.destroy();
await core.stop();
await core.drainPendingConnectionProcessing();
await vi.waitFor(() => expect(onCommittedEvent).toHaveBeenCalledTimes(2));
await new Promise<void>((resolveTurn) => setImmediate(resolveTurn));
await retirement;
wireSpy.mockRestore();
rmSync(root, { recursive: true, force: true });
}
},
);
it.each([false, true])(
"joins held authentication processing after stop without consuming its revoked admission (reopened ingress: %s)",
async (reopenedIngress) => {
const root = mkdtempSync(
resolve(tmpdir(), "runner-auth-processing-drain-"),
);
let release!: () => void;
const gate = new Promise<void>((resolveGate) => {
release = resolveGate;
});
let entered!: () => void;
const waiting = new Promise<void>((resolveEntered) => {
entered = resolveEntered;
});
const core = new DurablePrpControlPlane({
stateDirectory: root,
identity,
expectedRunnerVersion,
expectedRunnerDigest,
beforeAuthenticatedConnection: async () => {
entered();
await gate;
},
});
let authenticating: Promise<AuthenticatedClient | null> | undefined;
let drained: Promise<void> | undefined;
try {
await core.start();
const ticket = core.issueBootstrapTicket();
const ticketId = credentialMaterial(ticket).credentialId;
authenticating = authenticate(core, ticket);
await waiting;
await core.stop();
const settled = vi.fn();
drained = core.drainPendingConnectionProcessing().then(() => settled());
void drained.catch(() => undefined);
await new Promise<void>((resolveTurn) => setImmediate(resolveTurn));
expect(settled).not.toHaveBeenCalled();
if (reopenedIngress) await core.start();
release();
if (reopenedIngress) {
await expect(drained).rejects.toThrow("stopped ingress");
expect(settled).not.toHaveBeenCalled();
await core.stop();
} else {
await drained;
expect(settled).toHaveBeenCalledOnce();
}
await expect(authenticating).resolves.toBeNull();
expect(core.store.state.tickets[ticketId]!.usedAt).toBeNull();
expect(core.store.state.leases).toEqual({});
expect(core.store.state.connectionCount).toBe(0);
expect(core.store.state.commandDeliveryCounts).toEqual({});
await expect(
core.drainPendingConnectionProcessing(),
).resolves.toBeUndefined();
} finally {
release();
await core.stop();
await core.drainPendingConnectionProcessing();
await authenticating?.catch(() => undefined);
await drained?.catch(() => undefined);
rmSync(root, { recursive: true, force: true });
}
},
);
it("refuses a processing-drain absence claim while local or remote ingress remains admitted", async () => {
const root = mkdtempSync(
resolve(tmpdir(), "runner-processing-drain-ingress-"),
);
const core = new DurablePrpControlPlane({
stateDirectory: root,
identity,
expectedRunnerVersion,
expectedRunnerDigest,
});
try {
await core.start();
await expect(core.drainPendingConnectionProcessing()).rejects.toThrow(
"stopped ingress",
);
await core.stop();
let closed = () => {};
core.attachWireConnection({
onJson: () => {},
onClose: (listener) => {
closed = () => listener({ message: "closed" });
},
close: () => closed(),
sendJson: () => {},
});
await expect(core.drainPendingConnectionProcessing()).rejects.toThrow(
"stopped ingress",
);
await core.stop();
await expect(
core.drainPendingConnectionProcessing(),
).resolves.toBeUndefined();
} finally {
await core.stop();
await core.drainPendingConnectionProcessing();
rmSync(root, { recursive: true, force: true });
}
});
describe.sequential("DurablePrpControlPlane", () => {
it.each(["pending_first", "all_pending", "completed_first"] as const)(
"retains unanswered semantic input across the bounded event window (%s)",

View File

@ -1460,6 +1460,7 @@ export class DurablePrpControlPlane {
#expectedRunnerDigest: string;
#server: Server | null = null;
#connections = new Set<AuthorityConnection>();
#connectionProcessing = new Map<AuthorityConnection, Promise<void>>();
#pendingSemanticCalls = new Set<string>();
#semanticResultPersistenceFailed = false;
#port: number | null = null;
@ -1574,6 +1575,23 @@ export class DurablePrpControlPlane {
}
}
/** Join admitted wire work after ingress has stopped, including queued
* frames on already-closed connections. This proves settlement, not a
* successful commit or reusable checkpoint; callers still inspect those
* durable receipts and own the wait's deadline. Ordinary stop stays bounded
* by socket ownership rather than arbitrary external commit callbacks. */
async drainPendingConnectionProcessing(): Promise<void> {
const assertIngressStopped = () => {
if (this.#server !== null || this.#connections.size !== 0)
throw new Error("Connection processing drain requires stopped ingress.");
};
assertIngressStopped();
while (this.#connectionProcessing.size > 0) {
await Promise.allSettled([...this.#connectionProcessing.values()]);
assertIngressStopped();
}
}
/** Forces a resumable re-authentication after an immutable run attachment rotates. */
disconnectActiveRunner(): void {
const connections = [...this.#connections];
@ -1981,6 +1999,13 @@ export class DurablePrpControlPlane {
processing = processing
.then(() => this.#handleJson(connection, value))
.catch(() => connection.close());
const tail = processing;
this.#connectionProcessing.set(connection, tail);
const release = () => {
if (this.#connectionProcessing.get(connection) === tail)
this.#connectionProcessing.delete(connection);
};
void tail.then(release, release);
},
onClose: () => this.#connections.delete(connection),
});

View File

@ -168,9 +168,12 @@ describe("managed Codex credentials", () => {
] as const)(
"tolerates one unrelated silent %s quorum listener",
async (_label, occupiedIndex) => {
const fixture = await credentialFixture();
const prepared =
occupiedIndex === 0 ? await silentPrimaryQuorumFixture() : null;
const fixture = prepared?.fixture ?? (await credentialFixture());
const ports = credentialLeasePorts(await realpath(fixture.home));
const occupied = await listenSilently(ports[occupiedIndex]);
const occupied =
prepared?.occupied ?? (await listenSilently(ports[occupiedIndex]));
try {
const lease = await stageManagedCodexCredential({
agentHomeDirectory: fixture.home,
@ -188,6 +191,62 @@ describe("managed Codex credentials", () => {
},
);
it("prepares a fresh silent-primary fixture without taking over a foreign listener", async () => {
const foreignFixture = await silentPrimaryQuorumFixture();
const collision = foreignFixture.fixture;
const collisionPort = credentialLeasePorts(collision.home)[0];
const foreign = foreignFixture.occupied;
const nextFixture = vi
.fn()
.mockResolvedValueOnce(collision)
.mockImplementation(credentialFixture);
let prepared:
Awaited<ReturnType<typeof silentPrimaryQuorumFixture>> | undefined;
try {
prepared = await silentPrimaryQuorumFixture(nextFixture);
expect(nextFixture.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(nextFixture.mock.calls.length).toBeLessThanOrEqual(8);
expect(prepared.fixture.home).not.toBe(collision.home);
await expect(listenSilently(collisionPort)).rejects.toMatchObject({
code: "EADDRINUSE",
});
await expect(
readFile(join(collision.home, "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await prepared?.occupied.close();
await foreign.close();
}
});
it("bounds silent-primary preparation and releases only its own partial reservations", async () => {
const foreignFixture = await silentPrimaryQuorumFixture(
credentialFixture,
1,
);
const collision = foreignFixture.fixture;
const ports = credentialLeasePorts(collision.home);
const foreign = foreignFixture.occupied;
const nextFixture = vi.fn(async () => collision);
let releasedPrimary: Awaited<ReturnType<typeof listenSilently>> | undefined;
try {
await expect(
silentPrimaryQuorumFixture(nextFixture),
).rejects.toMatchObject({ code: "EADDRINUSE" });
expect(nextFixture).toHaveBeenCalledTimes(8);
releasedPrimary = await listenSilently(ports[0]);
await expect(listenSilently(ports[1])).rejects.toMatchObject({
code: "EADDRINUSE",
});
await expect(
readFile(join(collision.home, "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await releasedPrimary?.close();
await foreign.close();
}
});
it("fails before auth mutation when two quorum candidates are occupied", async () => {
const fixture = await credentialFixture();
const destination = join(fixture.home, "auth.json");
@ -1592,7 +1651,49 @@ async function credentialFixture(): Promise<{ root: string; home: string }> {
const home = join(root, "codex-home");
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
return { root, home };
return { root, home: await realpath(home) };
}
async function silentPrimaryQuorumFixture(
nextFixture = credentialFixture,
occupiedIndex: 0 | 1 = 0,
) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const fixture = await nextFixture();
const owned: Array<Awaited<ReturnType<typeof listenSilently>>> = [];
try {
for (const port of credentialLeasePorts(fixture.home))
owned.push(await listenSilently(port));
} catch (error) {
const closed = await Promise.allSettled(
owned.map((listener) => listener.close()),
);
const closeFailure = closed.find(
(result) => result.status === "rejected",
);
if (closeFailure?.status === "rejected") throw closeFailure.reason;
if ((error as NodeJS.ErrnoException).code === "EADDRINUSE" && attempt < 7)
continue;
throw error;
}
// Only fixture preparation may retry. Release the two free candidates
// immediately before the caller stages once. A foreign bind racing this
// handoff remains a visible production-call failure, never a hidden retry.
const released = await Promise.allSettled(
owned
.filter((_, index) => index !== occupiedIndex)
.map((listener) => listener.close()),
);
const releaseFailure = released.find(
(result) => result.status === "rejected",
);
if (releaseFailure?.status === "rejected") {
await owned[occupiedIndex]!.close();
throw releaseFailure.reason;
}
return { fixture, occupied: owned[occupiedIndex]! };
}
throw new Error("Silent-primary credential fixture reservation exhausted.");
}
function credentialLeasePorts(home: string): readonly number[] {

View File

@ -1,5 +1,5 @@
import { readCodexThreadState, readCodexTurnMetadata, readCodexTurnItems } from "./codex-history.js";
import { codexRunUsage } from "./codex-usage-baseline.js";
import { codexRunUsage, observeCodexUsage } from "./codex-usage-baseline.js";
import { randomUUID } from "node:crypto";
import { NativeProviderTerminalFailure } from "../../contracts/native-session-backend.js";
@ -661,8 +661,21 @@ export class CodexHarnessSession
const reconciledUsage = boundedPayload(
record(thread.tokenUsage ?? snapshot.tokenUsage),
);
if (Object.keys(reconciledUsage).length > 0)
this.usageSnapshot = reconciledUsage;
if (Object.keys(reconciledUsage).length > 0) {
if (this.driverKind === "codex_app_server" && this.codexUsageBaseline) {
this.codexUsageBaseline = observeCodexUsage(
this.codexUsageBaseline,
reconciledUsage.total,
false,
);
this.usageSnapshot = {
...reconciledUsage,
...codexRunUsage(this.codexUsageBaseline),
};
} else {
this.usageSnapshot = reconciledUsage;
}
}
const activeTurns = turns.filter(
(turn) => text(turn.status) === "inProgress",
);

View File

@ -7,6 +7,218 @@ import {
} from "./codex-app-server-driver.test-support.js";
describe("Codex resume accounting through the production driver", () => {
it.each(["thread", "snapshot"] as const)(
"retains the same run delta through an active cold reconciliation: %s",
async (usageLocation) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const third = new FakeCodexTransport();
const driver = makeDriver([first, second, third], {
conversationMode: "direct",
});
const readResponse = (inputTokens: number, outputTokens: number) => {
const tokenUsage = {
total: { inputTokens, outputTokens },
last: { inputTokens: 40, outputTokens: 6 },
};
return {
thread: {
id: "thread-1",
sessionId: "provider-session-1",
cwd: WORKSPACE,
turns: [{ id: "turn-2", status: "inProgress", items: [] }],
...(usageLocation === "thread" ? { tokenUsage } : {}),
},
...(usageLocation === "snapshot" ? { tokenUsage } : {}),
};
};
const session = await driver.openSession({
runId: "first",
normalizedSessionId: "same-run-accounting",
workingDirectory: WORKSPACE,
});
let recoveredSession: typeof session | undefined;
try {
await session.startTurn({ message: { role: "user", text: "First" } });
first.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: { inputTokens: 100, outputTokens: 10 },
last: { inputTokens: 100, outputTokens: 10 },
},
});
first.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
await collectUntilTerminal(session.events());
await session.attachRun!({ runId: "second" });
first.turnStartResponse = Promise.resolve({
turn: { id: "turn-2", status: "inProgress", items: [] },
});
await session.startTurn({ message: { role: "user", text: "Second" } });
first.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-2",
tokenUsage: {
total: { inputTokens: 140, outputTokens: 16 },
last: { inputTokens: 40, outputTokens: 6 },
},
});
for await (const event of session.events()) {
if (event.payload.kind === "usage") break;
}
const persisted = JSON.parse(JSON.stringify(await session.snapshot!()));
expect(persisted).toMatchObject({
runId: "second",
activeTurnId: "turn-2",
codexUsageBaseline: {
baseline: { inputTokens: 100, outputTokens: 10 },
latest: { inputTokens: 140, outputTokens: 16 },
},
});
expect(await session.usage!()).toMatchObject({
runDelta: { inputTokens: 40, outputTokens: 6 },
});
await session.close();
second.readResponse = readResponse(140, 16);
const recovery = await driver.recoverSession(persisted);
expect(recovery.recovered).toBe(true);
recoveredSession = recovery.session!;
expect(await recoveredSession.snapshot!()).toMatchObject({
runId: "second",
activeTurnId: "turn-2",
codexUsageBaseline: persisted.codexUsageBaseline,
});
expect(
second.calls.filter((call) => call.method === "turn/start"),
).toEqual([]);
expect(await recoveredSession.usage!()).toMatchObject({
total: { inputTokens: 140, outputTokens: 16 },
runDelta: { inputTokens: 40, outputTokens: 6 },
});
for (const [
reportedInput,
reportedOutput,
latestInput,
latestOutput,
] of [
[140, 16, 140, 16],
[130, 13, 140, 16],
[150, 19, 150, 19],
]) {
second.readResponse = readResponse(reportedInput!, reportedOutput!);
await recoveredSession.reconcile!();
expect(await recoveredSession.usage!()).toEqual({
total: { inputTokens: latestInput, outputTokens: latestOutput },
last: { inputTokens: 40, outputTokens: 6 },
runDelta: {
inputTokens: latestInput! - 100,
outputTokens: latestOutput! - 10,
},
});
}
const advanced = JSON.parse(
JSON.stringify(await recoveredSession.snapshot!()),
);
expect(advanced.codexUsageBaseline).toEqual({
baseline: { inputTokens: 100, outputTokens: 10 },
latest: { inputTokens: 150, outputTokens: 19 },
});
expect(persisted.codexUsageBaseline.latest).toEqual({
inputTokens: 140,
outputTokens: 16,
});
await recoveredSession.close();
third.readResponse = readResponse(150, 19);
const secondRecovery = await driver.recoverSession(advanced);
expect(secondRecovery.recovered).toBe(true);
recoveredSession = secondRecovery.session!;
expect(await recoveredSession.usage!()).toMatchObject({
total: { inputTokens: 150, outputTokens: 19 },
runDelta: { inputTokens: 50, outputTokens: 9 },
});
expect((await recoveredSession.snapshot!()).codexUsageBaseline).toEqual(
advanced.codexUsageBaseline,
);
expect(
[...second.calls, ...third.calls].filter(
(call) => call.method === "turn/start",
),
).toEqual([]);
} finally {
await recoveredSession?.close();
await session.close();
}
},
);
it.each([
{ driverKind: "codex_app_server", retainedBaseline: false },
{ driverKind: "other_protocol_facade", retainedBaseline: true },
])(
"preserves raw reconciliation usage outside an existing Codex baseline: $driverKind",
async ({ driverKind, retainedBaseline }) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second], {
conversationMode: "direct",
driverIdentity: {
kind: driverKind,
displayName: "Accounting boundary",
version: "test",
},
});
const session = await driver.openSession({
runId: "unchanged",
normalizedSessionId: "raw-reconcile-accounting",
workingDirectory: WORKSPACE,
});
let recoveredSession: typeof session | undefined;
try {
await session.startTurn({
message: { role: "user", text: "Continue" },
});
const persisted = JSON.parse(JSON.stringify(await session.snapshot!()));
expect(persisted.codexUsageBaseline).toBeUndefined();
// An optional retained accounting field must not opt another facade in.
if (retainedBaseline)
persisted.codexUsageBaseline = {
baseline: { inputTokens: 100 },
latest: { inputTokens: 140 },
};
await session.close();
const tokenUsage = {
total: { inputTokens: 150 },
last: { inputTokens: 10 },
};
second.readResponse = {
thread: {
id: "thread-1",
sessionId: "provider-session-1",
cwd: WORKSPACE,
turns: [{ id: "turn-1", status: "inProgress", items: [] }],
tokenUsage,
},
};
const recovery = await driver.recoverSession(persisted);
expect(recovery.recovered).toBe(true);
recoveredSession = recovery.session!;
expect(await recoveredSession.usage!()).toEqual(tokenUsage);
expect((await recoveredSession.snapshot!()).codexUsageBaseline).toEqual(
persisted.codexUsageBaseline,
);
expect(
second.calls.filter((call) => call.method === "turn/start"),
).toEqual([]);
} finally {
await recoveredSession?.close();
await session.close();
}
},
);
it("retains a run delta across repeated historical snapshots and a cold resume", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();

View File

@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@ -68,6 +68,7 @@ describe("runnerd artifact metadata", () => {
try {
await writeFile(executablePath, verifiedScript, { mode: 0o700 });
const canonicalExecutablePath = await realpath(executablePath);
const input = {
executablePath,
expectedSha256,
@ -78,7 +79,7 @@ describe("runnerd artifact metadata", () => {
};
await expect(resolvePaperclipRunnerdArtifact(input)).resolves.toMatchObject({
executablePath,
executablePath: canonicalExecutablePath,
sha256: expectedSha256,
byteSize: Buffer.byteLength(verifiedScript),
buildMetadata: valid,

View File

@ -6,9 +6,9 @@ export type CapabilityPrimaryDisposition =
| "optional_agent_tool";
export const capabilityInventoryCounts = {
"skillReferenceCapabilities": 152,
"skillReferenceCapabilities": 153,
"evalCases": 106,
"normativeRows": 258,
"normativeRows": 259,
"legacyMcpAliases": 42
} as const;

View File

@ -381,7 +381,218 @@ class TransientFailureLiveSessionStore implements CapabilityLiveSessionStore {
}
}
function observeSavedEffect(
store: CapabilityLiveSessionStore,
expected: { sessionId: string; runId: string; turnId: string; body: string },
) {
let resolveSaved!: (snapshot: CapabilityLiveSessionSnapshot) => void;
const saved = new Promise<CapabilityLiveSessionSnapshot>((resolve) => {
resolveSaved = resolve;
});
const originalSave = store.save.bind(store);
const saveSpy = vi
.spyOn(store, "save")
.mockImplementation(async (snapshot) => {
await originalSave(snapshot);
// A readable renamed checkpoint or a tool_result event is not the durable
// save acknowledgment: the real store must finish its directory fsync too.
if (
snapshot.sessionId === expected.sessionId &&
snapshot.authority.runId === expected.runId &&
snapshot.activeTurnId === expected.turnId &&
snapshot.mockState.includes(expected.body) &&
typeof snapshot.process?.runnerPid === "number" &&
typeof snapshot.process?.codexPid === "number"
)
resolveSaved(snapshot);
});
return { saved, restore: () => saveSpy.mockRestore() };
}
async function waitForSavedEffect(
saved: Promise<CapabilityLiveSessionSnapshot>,
turnOutcome: Promise<unknown>,
signal: AbortSignal,
): Promise<CapabilityLiveSessionSnapshot> {
let onAbort!: () => void;
try {
signal.throwIfAborted();
return await Promise.race([
saved,
turnOutcome.then((error) => {
throw (
error ??
new Error("Turn completed before its durable effect was observed")
);
}),
new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
}),
]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
describe("Capability live runnerd and Codex session", () => {
it("waits for the effect store save to finish before authorizing termination", async ({
signal,
}) => {
const state = providerState();
state.holdAfterTool = true;
const delegate = new InMemoryCapabilityLiveSessionStore();
const body = "Progress persisted through the live Codex tool loop.";
let releaseSave!: () => void;
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
let resolveSaving!: (snapshot: CapabilityLiveSessionSnapshot) => void;
const saving = new Promise<CapabilityLiveSessionSnapshot>((resolve) => {
resolveSaving = resolve;
});
const store: CapabilityLiveSessionStore = {
load: (id) => delegate.load(id),
delete: (id) => delegate.delete(id),
async save(snapshot) {
if (snapshot.mockState.includes(body)) {
resolveSaving(snapshot);
await saveGate;
}
await delegate.save(snapshot);
},
};
const service = new CapabilityLiveSessionService({
store,
transportFactory: fakeTransportFactory(state),
});
const session = await service.create({
sessionId: "session-save-boundary",
runId: "run-save-boundary",
turnTimeoutMs: 2_000,
});
const observed = observeSavedEffect(store, {
sessionId: session.id,
runId: "run-save-boundary",
turnId: "turn-1",
body,
});
let acknowledged = false;
void observed.saved.then(() => {
acknowledged = true;
});
const turn = captureTurnRejection(
session.sendMessage("Apply idempotent progress once."),
);
try {
await waitForSavedEffect(saving, turn, signal);
expect((await delegate.load(session.id))?.mockState).not.toContain(body);
expect(acknowledged).toBe(false);
releaseSave();
const checkpoint = await waitForSavedEffect(observed.saved, turn, signal);
expect(checkpoint.mockState).toContain(body);
expect(acknowledged).toBe(true);
// Exercise the observer with this actual effect snapshot, independently
// of the live session: wrong ownership, incomplete process evidence, and
// a rejected save must never acknowledge a durable effect.
let failSave = false;
const rejectingStore: CapabilityLiveSessionStore = {
load: async () => null,
delete: async () => undefined,
save: async () => {
if (failSave) throw new Error("controlled durable save failure");
},
};
const rejected = observeSavedEffect(rejectingStore, {
sessionId: session.id,
runId: "run-save-boundary",
turnId: "turn-1",
body,
});
let incorrectlyAcknowledged = false;
void rejected.saved.then(() => {
incorrectlyAcknowledged = true;
});
try {
const mutations: Array<
(snapshot: CapabilityLiveSessionSnapshot) => void
> = [
(snapshot) => {
snapshot.sessionId = "another-session";
},
(snapshot) => {
snapshot.authority.runId = "another-run";
},
(snapshot) => {
snapshot.activeTurnId = "another-turn";
},
(snapshot) => {
snapshot.mockState = "no governed effect yet";
},
(snapshot) => {
snapshot.process = { ...snapshot.process!, runnerPid: null };
},
(snapshot) => {
snapshot.process = { ...snapshot.process!, codexPid: null };
},
];
for (const mutate of mutations) {
const invalid = structuredClone(checkpoint);
mutate(invalid);
await rejectingStore.save(invalid);
expect(incorrectlyAcknowledged).toBe(false);
}
failSave = true;
await expect(rejectingStore.save(checkpoint)).rejects.toThrow(
"controlled durable save failure",
);
expect(incorrectlyAcknowledged).toBe(false);
} finally {
rejected.restore();
}
} finally {
releaseSave();
try {
const results = await Promise.allSettled([
service.shutdown(session.id),
turn,
]);
for (const result of results)
if (result.status === "rejected") throw result.reason;
} finally {
observed.restore();
}
}
});
it.each([new Error("real provider turn failed"), null])(
"does not authorize termination when the turn settles without a saved effect (%s)",
async (outcome) => {
const saved = new Promise<CapabilityLiveSessionSnapshot>(() => undefined);
const controller = new AbortController();
await expect(
waitForSavedEffect(saved, Promise.resolve(outcome), controller.signal),
).rejects.toThrow(
outcome?.message ??
"Turn completed before its durable effect was observed",
);
},
);
it("aborts the durable-effect wait with the test rather than leaving a detached waiter", async () => {
const saved = new Promise<CapabilityLiveSessionSnapshot>(() => undefined);
const turn = new Promise<unknown>(() => undefined);
const controller = new AbortController();
const removeListener = vi.spyOn(controller.signal, "removeEventListener");
const wait = waitForSavedEffect(saved, turn, controller.signal);
const result = expect(wait).rejects.toThrow("test aborted");
controller.abort(new Error("test aborted"));
await result;
expect(removeListener).toHaveBeenCalledWith("abort", expect.any(Function));
removeListener.mockRestore();
});
it("continues persisting newer snapshots after a transient store failure", async () => {
const state = providerState();
const store = new TransientFailureLiveSessionStore();
@ -1583,12 +1794,21 @@ describe("Capability live runnerd and Codex session", () => {
await expect(heldTurn).resolves.toMatchObject({ message: expect.stringContaining("timed out") });
});
it.skipIf(process.platform === "win32" || !existsSync(defaultCapabilityRunnerdBinary()))(
it.skipIf(
process.platform === "win32" || !existsSync(defaultCapabilityRunnerdBinary()),
)(
"terminates real runnerd after a durable receipt and resumes its exact provider thread",
async () => {
const directory = await mkdtemp(join(tmpdir(), "capability-live-real-runnerd-"));
async ({ signal }) => {
const directory = await mkdtemp(
join(tmpdir(), "capability-live-real-runnerd-"),
);
const providerStatePath = join(directory, "provider-state.json");
const fixture = fileURLToPath(new URL("../../test/fixtures/fake-durable-codex-app-server.mjs", import.meta.url));
const fixture = fileURLToPath(
new URL(
"../../test/fixtures/fake-durable-codex-app-server.mjs",
import.meta.url,
),
);
const binding = {
sessionId: "session-real-runnerd-resume",
runId: "run-real-runnerd-resume",
@ -1603,70 +1823,124 @@ describe("Capability live runnerd and Codex session", () => {
// Use the production close budget for this successful durable-close
// proof. The killed first generation is interrupted explicitly below.
};
const firstService = new CapabilityLiveSessionService({ store, transportOptions });
const firstService = new CapabilityLiveSessionService({
store,
transportOptions,
});
const first = await firstService.create({
...binding,
workingDirectory: directory,
attemptId: "attempt-real-killed",
turnTimeoutMs: 2_000,
});
const killedTurn = captureTurnRejection(first.sendMessage("Apply the governed idempotent effect."));
await vi.waitFor(async () => {
const observed = observeSavedEffect(store, {
...binding,
turnId: "turn-1",
body: "One durable governed effect.",
});
const killedTurn = captureTurnRejection(
first.sendMessage("Apply the governed idempotent effect."),
);
let turnSettled = false;
let turnOutcome: unknown;
void killedTurn.then((outcome) => {
turnSettled = true;
turnOutcome = outcome;
});
let resumedService: CapabilityLiveSessionService | null = null;
try {
// Observe the actual completed durable write, not a polling clock started
// before admission. The provider's 2s timer and this test's deadline still
// reject the wait if no effect arrives; neither deadline is extended.
await waitForSavedEffect(observed.saved, killedTurn, signal);
const checkpoint = await store.load(binding.sessionId);
expect(checkpoint?.mockState).toContain("One durable governed effect.");
expect(checkpoint?.activeTurnId).toBe("turn-1");
expect(checkpoint?.process?.runnerPid).not.toBeNull();
expect(checkpoint?.process?.codexPid).not.toBeNull();
}, { timeout: 2_000 }); // Match this turn's declared budget, not waitFor's shorter default.
await first.recordUsage({
receiptId: "real-response-1",
providerResponseId: "fixture-response-1",
turnId: "turn-1",
providerCalls: 1,
inputTokens: 10,
outputTokens: 2,
costNanodollars: 100,
});
const killedCheckpoint = await store.load(binding.sessionId);
const runnerPid = killedCheckpoint?.process?.runnerPid;
expect(runnerPid).toBeTypeOf("number");
process.kill(runnerPid!, "SIGKILL");
await expect(killedTurn).resolves.toBeInstanceOf(Error);
await first.recordUsage({
receiptId: "real-response-1",
providerResponseId: "fixture-response-1",
turnId: "turn-1",
providerCalls: 1,
inputTokens: 10,
outputTokens: 2,
costNanodollars: 100,
});
const killedCheckpoint = await store.load(binding.sessionId);
const runnerPid = killedCheckpoint?.process?.runnerPid;
expect(runnerPid).toBeTypeOf("number");
expect(
turnSettled,
`Turn settled before the intentional SIGKILL: ${String(turnOutcome)}`,
).toBe(false);
process.kill(runnerPid!, "SIGKILL");
await expect(killedTurn).resolves.toBeInstanceOf(Error);
const resumedService = new CapabilityLiveSessionService({
store: new DurableCapabilityLiveSessionStore({ directory, binding }),
transportOptions,
});
const resumed = await resumedService.resume({
sessionId: binding.sessionId,
attemptId: "attempt-real-resumed",
resumeOf: "attempt-real-killed",
// Smaller than this test's own timeout, so a stalled turn reports
// which turn stalled instead of surfacing only as a bare test timeout.
turnTimeoutMs: 10_000,
});
expect(resumed.snapshot().providerThreadId).toBe("thread-durable-runnerd");
const reconciled = await resumed.reconcileActiveTurn();
if (reconciled === null) throw new Error("checkpointed turn was not reconciled");
// Recovery may observe the provider's authoritative completion before
// the controller's interrupt wins the race; either terminal settles the
// exact checkpointed turn without replaying its governed effect.
expect(["completed", "interrupted"]).toContain(reconciled.status);
const duplicate = await resumed.sendMessage("Apply the governed idempotent effect again.");
expect(duplicate.assistantText).toContain("duplicate");
expect(resumed.mockState().comments).toHaveLength(1);
await resumed.completeAttempt("succeeded");
const final = await store.load(binding.sessionId);
expect(final?.attempts).toMatchObject([
{ attemptId: "attempt-real-killed", status: "terminated" },
{ attemptId: "attempt-real-resumed", status: "succeeded", resumeOf: "attempt-real-killed" },
]);
expect(final?.usageLedger).toHaveLength(2);
expect(final?.terminalTurns).toEqual(expect.arrayContaining([
expect.objectContaining({ turnId: "turn-1", status: reconciled.status }),
expect.objectContaining({ turnId: "turn-2", status: "completed" }),
]));
await resumedService.shutdown(resumed.id, "test complete");
resumedService = new CapabilityLiveSessionService({
store: new DurableCapabilityLiveSessionStore({ directory, binding }),
transportOptions,
});
const resumed = await resumedService.resume({
sessionId: binding.sessionId,
attemptId: "attempt-real-resumed",
resumeOf: "attempt-real-killed",
// Smaller than this test's own timeout, so a stalled turn reports
// which turn stalled instead of surfacing only as a bare test timeout.
turnTimeoutMs: 10_000,
});
expect(resumed.snapshot().providerThreadId).toBe(
"thread-durable-runnerd",
);
const reconciled = await resumed.reconcileActiveTurn();
if (reconciled === null)
throw new Error("checkpointed turn was not reconciled");
// Recovery may observe the provider's authoritative completion before
// the controller's interrupt wins the race; either terminal settles the
// exact checkpointed turn without replaying its governed effect.
expect(["completed", "interrupted"]).toContain(reconciled.status);
const duplicate = await resumed.sendMessage(
"Apply the governed idempotent effect again.",
);
expect(duplicate.assistantText).toContain("duplicate");
expect(resumed.mockState().comments).toHaveLength(1);
await resumed.completeAttempt("succeeded");
const final = await store.load(binding.sessionId);
expect(final?.attempts).toMatchObject([
{ attemptId: "attempt-real-killed", status: "terminated" },
{
attemptId: "attempt-real-resumed",
status: "succeeded",
resumeOf: "attempt-real-killed",
},
]);
expect(final?.usageLedger).toHaveLength(2);
expect(final?.terminalTurns).toEqual(
expect.arrayContaining([
expect.objectContaining({
turnId: "turn-1",
status: reconciled.status,
}),
expect.objectContaining({ turnId: "turn-2", status: "completed" }),
]),
);
} finally {
try {
// Once resumed, that service owns the newest checkpoint. The killed
// generation must not write its stale snapshot over the new owner.
const results = await Promise.allSettled([
(resumedService ?? firstService).shutdown(
binding.sessionId,
"test complete",
),
killedTurn,
]);
for (const result of results)
if (result.status === "rejected") throw result.reason;
} finally {
observed.restore();
}
}
},
// CI exercises two real process generations here and can exceed the unit default under load.
30_000,

View File

@ -260,6 +260,33 @@ it("replaces an owned v1 runner with fresh v2 authorization before warm attachme
}
}, 30_000);
function maintenanceFixtureBackendName(fixtureId: string): string {
return `maintenance-test-${fixtureId}`;
}
function maintenanceReplaySnapshot(directory: string) {
const bytes = [
"control-plane/control-plane-state.json",
"runner/runner-state.json",
"runner/codex-provider-state.json",
].map((file) => readFileSync(join(directory, file)));
return {
control: JSON.parse(bytes[0]!.toString("utf8")),
runner: JSON.parse(bytes[1]!.toString("utf8")),
provider: JSON.parse(bytes[2]!.toString("utf8")),
providerFingerprint: createHash("sha256").update(bytes[2]!).digest("hex"),
fingerprint: createHash("sha256")
.update(
JSON.stringify(
bytes.map((value) =>
createHash("sha256").update(value).digest("hex"),
),
),
)
.digest("hex"),
};
}
it.each([
{ alreadyEnded: false, appendFailure: false },
{ alreadyEnded: true, appendFailure: false },
@ -269,6 +296,12 @@ it.each([
{ alreadyEnded: true, appendFailure: false, epochFailure: "spawned" },
{ alreadyEnded: true, appendFailure: false, epochFailure: "retired" },
{ alreadyEnded: true, appendFailure: false, holdSpawned: true },
{ alreadyEnded: true, appendFailure: false, completedTerminalAck: "pending" },
{ alreadyEnded: true, appendFailure: false, completedTerminalAck: "completed" },
{ alreadyEnded: true, appendFailure: false, completedTerminalAck: "repeat" },
{ alreadyEnded: true, appendFailure: false, finalRetirementRevocation: "abort" },
{ alreadyEnded: true, appendFailure: false, finalRetirementRevocation: "revoked" },
{ alreadyEnded: true, appendFailure: false, finalRetirementRevocation: "during_authorize" },
{ alreadyEnded: true, appendFailure: false, homeScoped: true },
{ alreadyEnded: true, appendFailure: false, homeScoped: true, missingHome: true },
{ alreadyEnded: true, appendFailure: false, homeScoped: true, missingHome: true, unknownExit: true },
@ -280,7 +313,7 @@ it.each([
terminalReplay: true,
},
])(
"settles only retained control authority without starting another provider turn ($alreadyEnded/$appendFailure/$bareCodex/$epochFailure/$terminalReplay/$holdSpawned/$homeScoped/$missingHome/$unknownExit) startup-failure-proof=$startupFailureProof",
"settles only retained control authority without starting another provider turn ($alreadyEnded/$appendFailure/$bareCodex/$epochFailure/$terminalReplay/$holdSpawned/$homeScoped/$missingHome/$unknownExit) startup-failure-proof=$startupFailureProof completed-ack=$completedTerminalAck retired-revocation=$finalRetirementRevocation",
async ({
alreadyEnded,
appendFailure,
@ -288,11 +321,23 @@ it.each([
epochFailure,
terminalReplay,
holdSpawned,
completedTerminalAck,
finalRetirementRevocation,
homeScoped,
missingHome,
unknownExit,
startupFailureProof,
}) => {
const replaySpyRestorers: Array<() => void> = [];
const replayRetirements: ReturnType<typeof maintenanceReplaySnapshot>[] = [];
const withheldTerminalFrames: Array<{
epoch: number;
direction: "inbound" | "outbound";
commandId: string;
controllerSeq: number;
count: number;
}> = [];
const fixtureId = randomUUID();
const fixtureRunner = defaultCapabilityRunnerdBinary();
const directory = await mkdtemp(join(tmpdir(), "runnerd-maintenance-"));
const original = join(directory, "original");
@ -506,7 +551,17 @@ it.each([
)
.digest("hex");
const appendEvent = vi.fn(async (_event: PrpEvent) => {});
const authorize = vi.fn(async () => {});
const maintenanceAbort = new AbortController();
let finalAuthorityRevoked = false;
const authorize = vi.fn(async () => {
if (finalAuthorityRevoked) {
if (finalRetirementRevocation === "during_authorize") {
maintenanceAbort.abort(new Error("retirement_authority_revoked"));
return;
}
throw new Error("retirement_authority_revoked");
}
});
const recordEpoch = vi.fn(
async (_receipt: Record<string, unknown>) => {},
);
@ -521,13 +576,7 @@ it.each([
},
backend: {
kind: "codex",
name: missingHome
? `maintenance-test-missing-home-${Boolean(unknownExit)}${startupFailureProof ? "-startup-proof" : ""}`
: epochFailure
? `maintenance-test-${epochFailure}`
: appendFailure
? "maintenance-test-failure"
: "maintenance-test",
name: maintenanceFixtureBackendName(fixtureId),
},
identity,
stateDirectory: copy,
@ -542,6 +591,7 @@ it.each([
authorize,
appendEvent,
recordEpoch,
signal: maintenanceAbort.signal,
};
const close = vi.fn(async () => {
throw new NativeSessionCloseUnrecoverableError();
@ -650,6 +700,38 @@ it.each([
code: "native_session_cleanup_quarantined",
});
expect(start).toHaveBeenCalledOnce();
if (holdSpawned) {
// A preceding row can fail before its authenticated cleanup proof.
// That sticky quarantine must remain, but must not contaminate the
// next independent fixture's backend domain in the same worker.
const independentStart = vi.fn(async () => {
throw new Error("independent fixture admission reached");
});
const independentBackend: NativeSessionBackend = {
descriptor: async () => ({
...(await backend.descriptor()),
name: maintenanceFixtureBackendName(`${fixtureId}-following`),
}),
openSession: async () => ({
...session,
startTurn: independentStart,
close: async () => {},
}),
};
await expect(executeNativeSession({
input: nativeInput,
backend: independentBackend,
controlPlane: port,
runnerInstanceId: identity.runnerInstanceId,
controlPlaneInstanceId: "independent-control-maintenance",
requireSessionCloseBeforeReturn: true,
})).rejects.toThrow("independent fixture admission reached");
expect(independentStart).toHaveBeenCalledOnce();
await expect(execute()).rejects.toMatchObject({
code: "native_session_cleanup_quarantined",
});
expect(start).toHaveBeenCalledOnce();
}
await expect(
settleRetainedRunnerdSession({
...input,
@ -1078,6 +1160,100 @@ it.each([
await assertNoProviderBeforeSpawnedReceipt();
});
}
if (completedTerminalAck) {
const originalAttach =
DurablePrpControlPlane.prototype.attachWireConnection;
const attachSpy = vi
.spyOn(DurablePrpControlPlane.prototype, "attachWireConnection")
.mockImplementation(function (this: DurablePrpControlPlane, wire) {
const epoch = replayRetirements.length;
const direction =
completedTerminalAck === "completed" ? "outbound" : "inbound";
const inject =
this.store.path === join(copy, files[0]!) &&
(epoch === 0 ||
(completedTerminalAck === "repeat" && epoch === 1));
let attachment: ReturnType<typeof originalAttach> | undefined;
let withheld: (typeof withheldTerminalFrames)[number] | undefined;
const shouldWithhold = (candidate: typeof direction): boolean => {
if (
!inject ||
candidate !== direction ||
!attachment?.isAuthenticated()
)
return false;
if (withheld) {
withheld.count += 1;
return true;
}
const runner = JSON.parse(
readFileSync(join(copy, files[1]!), "utf8"),
);
const terminal = runner.pendingTerminalDelivery;
if (
terminal?.commandType !== "runner.suspend" ||
terminal.lifecycle !== "suspended"
)
return false;
const result = runner.processedCommands[terminal.commandId];
if (
result?.status !== "completed" ||
result.result?.status !== "completed" ||
result.commandType !== terminal.commandType ||
result.controllerSeq !== terminal.controllerSeq
)
return false;
const control = JSON.parse(readFileSync(this.store.path, "utf8"));
const command = control.commands.find(
(entry: { commandId: string }) =>
entry.commandId === terminal.commandId,
);
if (
command?.type !== terminal.commandType ||
command.controllerSeq !== terminal.controllerSeq ||
command.status !==
(direction === "inbound" ? "pending" : "completed")
)
return false;
if (direction === "outbound")
expect(command.result).toEqual(result);
else expect(command.result ?? null).toBeNull();
// Rust durably records this exact result before sending it.
// Withhold transport delivery only after that handshake, not
// after a guessed number of saves or an elapsed sleep. Pending
// mode loses the result; completed mode loses its outbound ACK.
// No retained journal/outbox bytes are removed or rewritten.
withheld = {
epoch,
direction,
commandId: terminal.commandId,
controllerSeq: terminal.controllerSeq,
count: 1,
};
withheldTerminalFrames.push(withheld);
return true;
};
attachment = originalAttach.call(this, {
sendJson(value) {
if (!shouldWithhold("outbound")) wire.sendJson(value);
},
close: (code) => wire.close(code),
onJson: (listener) =>
wire.onJson((value) => {
if (!shouldWithhold("inbound")) listener(value);
}),
onClose: (listener) => wire.onClose(listener),
});
return attachment;
});
replaySpyRestorers.push(() => attachSpy.mockRestore());
recordEpoch.mockImplementation(async (receipt) => {
if (receipt.phase !== "retired") return;
const snapshot = maintenanceReplaySnapshot(copy);
expect(snapshot.fingerprint).toBe(receipt.finalFingerprint);
replayRetirements.push(snapshot);
});
}
if (epochFailure) {
const failure = new Error("injected epoch receipt persistence failure");
const callsBefore = await readFile(calls, "utf8");
@ -1115,6 +1291,28 @@ it.each([
expect(start).toHaveBeenCalledOnce();
return;
}
if (finalRetirementRevocation) {
recordEpoch.mockImplementation(async (receipt) => {
if (receipt.phase !== "retired") return;
const state = maintenanceReplaySnapshot(copy);
if (
state.runner.pendingTerminalDelivery != null ||
state.runner.pendingProviderCleanup != null ||
state.runner.outbox.length !== 0 ||
state.provider.pendingEvents.length !== 0 ||
state.provider.queuedEvents.length !== 0 ||
state.control.commands.some(
(command: { status: string }) => command.status === "pending",
)
)
return;
expect(state.provider.lifecycle).toBe("prepared");
expect(state.fingerprint).toBe(receipt.finalFingerprint);
finalAuthorityRevoked = true;
if (finalRetirementRevocation === "abort")
maintenanceAbort.abort(new Error("retirement_authority_revoked"));
});
}
let failedAttempt: { directory: string; bytes: Buffer[] } | null = null;
let failedStartupAttempt: {
directory: string;
@ -1259,7 +1457,95 @@ it.each([
recordEpoch.mockClear();
appendEvent.mockClear();
}
const proof = await settleRetainedRunnerdSession(input).catch(
const pendingProof = settleRetainedRunnerdSession(input);
if (finalRetirementRevocation) {
await expect(pendingProof).rejects.toThrow(
finalRetirementRevocation === "abort"
? "native_cleanup_maintenance_unproven"
: "retirement_authority_revoked",
);
expect(finalAuthorityRevoked).toBe(true);
await expect(
readFile(join(activated, "runner/runner-state.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(execute()).rejects.toMatchObject({
code: "native_session_cleanup_quarantined",
});
expect(
await Promise.all(files.map((file) => readFile(join(original, file)))),
).toEqual(bytes);
const methods = (await readFile(calls, "utf8")).trim().split("\n");
expect(methods.filter((method) => method === "turn/start")).toHaveLength(1);
return;
}
if (completedTerminalAck === "repeat") {
await expect(pendingProof).rejects.toThrow(
"native_cleanup_maintenance_unproven",
);
expect(replayRetirements).toHaveLength(2);
expect(
withheldTerminalFrames.map(({ epoch, direction }) => ({
epoch,
direction,
})),
).toEqual([
{ epoch: 0, direction: "inbound" },
{ epoch: 1, direction: "inbound" },
]);
for (const [epoch, state] of replayRetirements.entries()) {
expect(JSON.stringify(state.runner.diagnostics)).toContain(
"terminal command result acknowledgement timed out",
);
expect(state.runner.pendingTerminalDelivery).toMatchObject({
commandType: "runner.suspend",
lifecycle: "suspended",
});
const withheld = withheldTerminalFrames[epoch]!;
expect(withheld.count).toBeGreaterThan(0);
expect(state.runner.pendingTerminalDelivery).toMatchObject({
commandId: withheld.commandId,
controllerSeq: withheld.controllerSeq,
});
expect(
state.runner.processedCommands[withheld.commandId],
).toMatchObject({
status: "completed",
result: { status: "completed" },
});
expect(
state.control.commands.find(
(command: { commandId: string }) =>
command.commandId === withheld.commandId,
),
).toMatchObject({ status: "pending" });
}
expect(replayRetirements[1]!.provider).toEqual(
replayRetirements[0]!.provider,
);
expect(recordEpoch.mock.calls).toHaveLength(6);
await expect(
readFile(join(activated, "runner/runner-state.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(execute()).rejects.toMatchObject({
code: "native_session_cleanup_quarantined",
});
expect(
await Promise.all(
files.map((file) => readFile(join(original, file))),
),
).toEqual(bytes);
const methods = (await readFile(calls, "utf8")).trim().split("\n");
expect(
methods.filter((method) => method === "turn/start"),
).toHaveLength(1);
// The first cleanup epoch restored the old provider solely to stop
// it; the failed delivery-only replay did not restore it again.
expect(
methods.filter((method) => method === "thread/resume"),
).toHaveLength(1);
return;
}
const proof = await pendingProof.catch(
async (error: unknown) => {
const runner = JSON.parse(
await readFile(join(copy, "runner/runner-state.json"), "utf8"),
@ -1365,6 +1651,199 @@ it.each([
).toBe(true);
}
const epochReceipts = recordEpoch.mock.calls.map(([receipt]) => receipt);
if (completedTerminalAck) {
const before = replayRetirements[0]!;
const after = replayRetirements[1]!;
expect(withheldTerminalFrames).toHaveLength(1);
expect(withheldTerminalFrames[0]).toMatchObject({
epoch: 0,
direction:
completedTerminalAck === "completed" ? "outbound" : "inbound",
});
expect(withheldTerminalFrames[0]!.count).toBeGreaterThan(0);
expect(JSON.stringify(before.runner.diagnostics)).toContain(
"terminal command result acknowledgement timed out",
);
const pending = before.runner.pendingTerminalDelivery;
expect(pending).toMatchObject({
commandType: "runner.suspend",
lifecycle: "suspended",
commandId: withheldTerminalFrames[0]!.commandId,
controllerSeq: withheldTerminalFrames[0]!.controllerSeq,
});
expect(before.runner.processedCommands[pending.commandId].status).toBe(
"completed",
);
expect(
before.control.commands.find(
(command: { commandId: string }) =>
command.commandId === pending.commandId,
).status,
).toBe(completedTerminalAck);
expect(
runnerdRecoveryInternals.completedMaintenanceTerminalReceipt(before),
).not.toBeNull();
expect(
runnerdRecoveryInternals.completedMaintenanceTerminalReplayMatches(
before,
after,
),
).toBe(true);
expect(after.provider).toEqual(before.provider);
expect(after.runner.pendingProviderCleanup ?? null).toBeNull();
// An actual completed receipt copied into an INITIAL invocation is
// not this invocation's joined retirement and cannot enable replay.
const initialTerminal = join(directory, "unproved-initial-terminal");
await mkdir(join(initialTerminal, "runner"), { recursive: true });
await mkdir(join(initialTerminal, "control-plane"));
for (const [index, value] of [
before.control,
before.runner,
before.provider,
].entries())
await writeFile(
join(initialTerminal, files[index]!),
JSON.stringify(value),
);
const unproved = maintenanceReplaySnapshot(initialTerminal);
const initialEpoch = vi.fn(async () => {});
await expect(
settleRetainedRunnerdSession({
...input,
stateDirectory: initialTerminal,
sourceFingerprint: unproved.fingerprint,
recordEpoch: initialEpoch,
}),
).rejects.toThrow("native_cleanup_maintenance_unproven");
expect(initialEpoch).not.toHaveBeenCalled();
const receiptCases: Array<[string, (state: typeof before) => void]> = [
[
"missing result",
(state) => {
delete state.runner.processedCommands[pending.commandId];
},
],
...["pending", "failed", "rejected", "indeterminate"].map(
(status): [string, (state: typeof before) => void] => [
`outer ${status}`,
(state) => {
state.runner.processedCommands[pending.commandId].status = status;
},
],
),
...["failed", "rejected", "indeterminate"].map(
(status): [string, (state: typeof before) => void] => [
`nested ${status}`,
(state) => {
state.runner.processedCommands[pending.commandId].result.status =
status;
},
],
),
[
"terminal sequence",
(state) => {
state.runner.pendingTerminalDelivery.controllerSeq++;
},
],
[
"terminal type",
(state) => {
state.runner.pendingTerminalDelivery.commandType = "runner.shutdown";
},
],
[
"wire fingerprint",
(state) => {
state.control.commands.find(
(command: { commandId: string }) =>
command.commandId === pending.commandId,
).payload = { changed: true };
},
],
[
"completed controller result",
(state) => {
const command = state.control.commands.find(
(entry: { commandId: string }) =>
entry.commandId === pending.commandId,
);
command.status = "completed";
command.result = { changed: true };
},
],
[
"earlier pending command",
(state) => {
const command = state.control.commands.find(
(entry: { commandId: string }) =>
entry.commandId === pending.commandId,
);
command.status = "pending";
delete command.result;
state.control.commands[0].status = "pending";
},
],
[
"active provider",
(state) => {
state.provider.activeProviderTurnId = "another-turn";
},
],
[
"provider cleanup",
(state) => {
state.runner.pendingProviderCleanup = pending;
},
],
];
for (const [name, mutate] of receiptCases) {
const changed = structuredClone(before);
mutate(changed);
expect(
runnerdRecoveryInternals.completedMaintenanceTerminalReceipt(changed),
name,
).toBeNull();
}
for (const [name, mutate] of [
[
"other command",
(state: typeof after) => {
state.control.commands[0].result = { changed: true };
},
],
[
"provider bytes",
(state: typeof after) => {
state.providerFingerprint = "changed";
},
],
[
"pending delivery",
(state: typeof after) => {
state.runner.pendingTerminalDelivery = pending;
},
],
[
"processed receipt",
(state: typeof after) => {
state.runner.processedCommands[pending.commandId].result = {
changed: true,
};
},
],
] as const) {
const changed = structuredClone(after);
mutate(changed);
expect(
runnerdRecoveryInternals.completedMaintenanceTerminalReplayMatches(
before,
changed,
),
name,
).toBe(false);
}
}
expect(epochReceipts.length).toBeGreaterThanOrEqual(3);
for (let index = 0; index < epochReceipts.length; index += 3) {
const [intent, spawned, retired] = epochReceipts.slice(
@ -1458,6 +1937,7 @@ it.each([
);
expect(retainedRunnerdCleanupProofIsCurrent(proof)).toBe(false);
} finally {
for (const restore of replaySpyRestorers.reverse()) restore();
await bundle.transport.close().catch(() => undefined);
for (const pid of [runnerPid, providerPid]) {
if (pid > 0 && !dead(pid)) {

View File

@ -2012,6 +2012,108 @@ function readMaintenanceState(root: string) {
};
}
/** An exact completed receipt is delivery evidence, never launch authority.
* The caller must additionally own the preceding retired maintenance epoch. */
function completedMaintenanceTerminalReceipt(
state: ReturnType<typeof readMaintenanceState>,
) {
const pending = record(state.runner.pendingTerminalDelivery);
const commands = state.control.commands as Array<Record<string, unknown>>;
if (
state.runner.lifecycle !== "suspended" ||
pending.commandType !== "runner.suspend" ||
pending.lifecycle !== "suspended" ||
typeof pending.commandId !== "string" ||
!pending.commandId ||
!Number.isSafeInteger(pending.controllerSeq) ||
Number(pending.controllerSeq) <= 0 ||
pending.controllerSeq !== state.runner.lastControllerCommandSeq ||
state.runner.pendingProviderCleanup != null ||
state.provider.lifecycle !== "prepared" ||
state.provider.activeProviderTurnId != null ||
!Array.isArray(commands)
)
return null;
const matching = commands.filter(
(command) => command.commandId === pending.commandId,
);
const command = matching[0];
const result = record(
record(state.runner.processedCommands)[pending.commandId],
);
if (
matching.length !== 1 ||
!command ||
command.type !== pending.commandType ||
command.controllerSeq !== pending.controllerSeq ||
result.commandId !== pending.commandId ||
result.commandType !== pending.commandType ||
result.controllerSeq !== pending.controllerSeq ||
result.status !== "completed" ||
record(result.result).status !== "completed"
)
return null;
// Match Rust's serialized Command, including nullable defaulted fields.
const wire = {
schema: command.schema,
commandId: command.commandId,
controllerSeq: command.controllerSeq,
type: command.type,
issuedAt: command.issuedAt,
deadlineAt: command.deadlineAt ?? null,
precondition: command.precondition ?? null,
payload: command.payload,
};
if (
record(state.runner.processedCommandFingerprints)[pending.commandId] !==
commandDigest(wire).slice("sha256:".length)
)
return null;
if (command.status === "pending") {
// Welcome advertises only the first pending command. Absence from that
// one-element list cannot prove a later terminal result was delivered.
if (
commands.find((entry) => entry.status === "pending") !== command ||
command.result != null
)
return null;
} else if (
command.status !== "completed" ||
commandDigest(command.result) !== commandDigest(result)
) {
return null;
}
return { commandId: pending.commandId, result };
}
function completedMaintenanceTerminalReplayMatches(
before: ReturnType<typeof readMaintenanceState>,
after: ReturnType<typeof readMaintenanceState>,
) {
const receipt = completedMaintenanceTerminalReceipt(before);
if (!receipt) return false;
const expectedCommands = (
before.control.commands as Array<Record<string, unknown>>
).map((command) =>
command.commandId === receipt.commandId
? { ...command, status: "completed", result: receipt.result }
: command,
);
return (
after.runner.lifecycle === "suspended" &&
after.runner.pendingTerminalDelivery == null &&
after.runner.pendingProviderCleanup == null &&
after.providerFingerprint === before.providerFingerprint &&
commandDigest(after.runner.processedCommands) ===
commandDigest(before.runner.processedCommands) &&
commandDigest(after.runner.processedCommandFingerprints) ===
commandDigest(before.runner.processedCommandFingerprints) &&
after.runner.lastControllerCommandSeq ===
before.runner.lastControllerCommandSeq &&
commandDigest(after.control.commands) === commandDigest(expectedCommands)
);
}
function assertMaintenanceBinding(
state: ReturnType<typeof readMaintenanceState>,
identity: DurableRecoveryIdentity,
@ -2229,6 +2331,11 @@ async function settleRetainedRunnerdSessionOwned(
if (failure) throw failure;
if (Date.now() >= deadline) throw maintenanceDenied();
await bounded(input.authorize());
// A callback can synchronously revoke/abort and return a resolved promise;
// that result must not win Promise.race over the already-latched denial.
input.signal?.throwIfAborted();
if (failure) throw failure;
if (Date.now() >= deadline) throw maintenanceDenied();
};
await authorize();
await bounded(
@ -2247,6 +2354,7 @@ async function settleRetainedRunnerdSessionOwned(
// A previously journaled suspend must be honored before a later drain.
// A second exact-authority connection can then drain the retained provider
// prefix; no old command is removed, reordered, or treated as completed.
let completedTerminalEpochFingerprint: string | null = null;
for (let epoch = 0; epoch < 4; epoch++) {
await authorize();
if (![...pids].every(maintenanceProcessAbsent)) throw maintenanceDenied();
@ -2254,8 +2362,14 @@ async function settleRetainedRunnerdSessionOwned(
assertMaintenanceBinding(before, input.identity, input.providerSessionId);
const terminalOnly = before.runner.pendingTerminalDelivery != null;
const pendingTerminal = record(before.runner.pendingTerminalDelivery);
const completedTerminalOnly =
terminalOnly &&
completedTerminalEpochFingerprint === before.fingerprint &&
completedMaintenanceTerminalReceipt(before) !== null;
completedTerminalEpochFingerprint = null;
if (
terminalOnly &&
!completedTerminalOnly &&
!(before.control.commands as Array<Record<string, unknown>>).some(
(command) =>
command.commandId === pendingTerminal.commandId &&
@ -2373,6 +2487,7 @@ async function settleRetainedRunnerdSessionOwned(
let handle: RunnerProcessHandle | null = null;
let exited = false;
let epochCompleted = false;
let retiredFingerprint: string | null = null;
const epochIdentity = {
schema: "paperclip.native_cleanup_runner_epoch.v1" as const,
requestId: input.requestId,
@ -2504,14 +2619,29 @@ async function settleRetainedRunnerdSessionOwned(
);
}
await core.stop();
let processingDrained = false;
try {
// Closing sockets does not join already queued JSON/auth callbacks.
// No retirement fingerprint or new core may race an old store write.
const normalRetirement = epochCompleted && !failure;
await bounded(
core.drainPendingConnectionProcessing(),
normalRetirement ? deadline : Math.min(deadline, Date.now() + 1_000),
!normalRetirement,
);
processingDrained = true;
} catch (error) {
failure ??= error;
}
// Do not mistake a bounded wait/kill attempt for retirement. Only the
// exact child's settled completion plus absence of its entire group can
// produce this durable receipt. A missing receipt remains unknown.
if (handle && spawnedReceipt && exited) {
if (handle && spawnedReceipt && exited && processingDrained) {
try {
const result = await handle.completion;
if (!maintenanceProcessAbsent(spawnedReceipt.pid))
throw maintenanceDenied();
const finalFingerprint = readMaintenanceState(root).fingerprint;
await bounded(
input.recordEpoch({
...spawnedReceipt,
@ -2520,11 +2650,12 @@ async function settleRetainedRunnerdSessionOwned(
exitSignal: result.signal,
processGroupAbsent: true,
retiredAt: new Date().toISOString(),
finalFingerprint: readMaintenanceState(root).fingerprint,
finalFingerprint,
}),
Date.now() + 1_000,
true,
);
retiredFingerprint = finalFingerprint;
} catch (error) {
failure ??= error;
}
@ -2565,10 +2696,26 @@ async function settleRetainedRunnerdSessionOwned(
}
}
if (failure) throw failure;
// Retirement itself may await durable authorization callbacks. It proves
// process exit, not permission to publish a reusable cleanup proof.
await authorize();
const settled = readMaintenanceState(root);
assertMaintenanceBinding(settled, input.identity, input.providerSessionId);
if (settled.runner.lifecycle !== "suspended") throw maintenanceDenied();
if (terminalOnly) {
if (completedTerminalOnly) {
// The previous epoch durably completed this command but missed its
// ACK. This epoch may only deliver that exact result and old outbox;
// it cannot restore a provider or count as a new physical stop.
if (
!completedMaintenanceTerminalReplayMatches(before, settled) ||
settled.fingerprint !== retiredFingerprint ||
epochProviderPids.size !== 0 ||
![...pids].every(maintenanceProcessAbsent)
)
throw maintenanceDenied();
continue;
}
// This epoch only confirms delivery of a failed old terminal receipt.
// It cannot count as provider cleanup or create/execute a command. A
// separate epoch must perform a NEW stop under the persistent marker.
@ -2655,6 +2802,14 @@ async function settleRetainedRunnerdSessionOwned(
);
if (!stopProven || ![...pids].every(maintenanceProcessAbsent))
throw maintenanceDenied();
if (completedMaintenanceTerminalReceipt(settled)) {
// Only a joined child whose retirement receipt committed in THIS
// invocation can enable the next delivery-only epoch. A copied initial
// terminal marker or an interrupted/unknown child remains quarantined.
if (!retiredFingerprint || settled.fingerprint !== retiredFingerprint)
throw maintenanceDenied();
completedTerminalEpochFingerprint = settled.fingerprint;
}
if (
settled.provider.lifecycle === "prepared" &&
settled.runner.pendingTerminalDelivery == null &&
@ -6402,6 +6557,8 @@ export const runnerdLaunchProfileInternals = Object.freeze({
});
export const runnerdRecoveryInternals = Object.freeze({
completedMaintenanceTerminalReceipt,
completedMaintenanceTerminalReplayMatches,
awaitProviderDrainBarrier,
awaitAdoptedRunnerAuthentication,
awaitRunnerSuspensionBarrier,

View File

@ -1,64 +1,67 @@
import a0 from "./app-definitions/zapier.json" with { type: "json" };
import a1 from "./app-definitions/github.json" with { type: "json" };
import a2 from "./app-definitions/slack.json" with { type: "json" };
import a3 from "./app-definitions/notion.json" with { type: "json" };
import a4 from "./app-definitions/posthog.json" with { type: "json" };
import a5 from "./app-definitions/linear.json" with { type: "json" };
import a6 from "./app-definitions/context7.json" with { type: "json" };
import a7 from "./app-definitions/shopify.json" with { type: "json" };
import a8 from "./app-definitions/composio.json" with { type: "json" };
import a9 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a10 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a11 from "./app-definitions/sentry.json" with { type: "json" };
import a12 from "./app-definitions/vercel.json" with { type: "json" };
import a13 from "./app-definitions/anthropic.json" with { type: "json" };
import a14 from "./app-definitions/jira.json" with { type: "json" };
import a15 from "./app-definitions/airtable.json" with { type: "json" };
import a16 from "./app-definitions/beehiiv.json" with { type: "json" };
import a17 from "./app-definitions/bitly.json" with { type: "json" };
import a18 from "./app-definitions/candid.json" with { type: "json" };
import a19 from "./app-definitions/cloudflare.json" with { type: "json" };
import a20 from "./app-definitions/cloudinary.json" with { type: "json" };
import a21 from "./app-definitions/coda.json" with { type: "json" };
import a22 from "./app-definitions/hugging-face.json" with { type: "json" };
import a23 from "./app-definitions/kernel.json" with { type: "json" };
import a24 from "./app-definitions/local-falcon.json" with { type: "json" };
import a25 from "./app-definitions/make.json" with { type: "json" };
import a26 from "./app-definitions/manufact.json" with { type: "json" };
import a27 from "./app-definitions/miro.json" with { type: "json" };
import a28 from "./app-definitions/netlify.json" with { type: "json" };
import a29 from "./app-definitions/oreilly.json" with { type: "json" };
import a30 from "./app-definitions/planetscale.json" with { type: "json" };
import a31 from "./app-definitions/resend.json" with { type: "json" };
import a32 from "./app-definitions/ticktick.json" with { type: "json" };
import a33 from "./app-definitions/todoist.json" with { type: "json" };
import a34 from "./app-definitions/webflow.json" with { type: "json" };
import a35 from "./app-definitions/wix.json" with { type: "json" };
import a36 from "./app-definitions/brex.json" with { type: "json" };
import a37 from "./app-definitions/clickhouse.json" with { type: "json" };
import a38 from "./app-definitions/egnyte.json" with { type: "json" };
import a39 from "./app-definitions/embat.json" with { type: "json" };
import a40 from "./app-definitions/mixpanel.json" with { type: "json" };
import a41 from "./app-definitions/postman.json" with { type: "json" };
import a42 from "./app-definitions/razorpay.json" with { type: "json" };
import a43 from "./app-definitions/sanity.json" with { type: "json" };
import a44 from "./app-definitions/stripe.json" with { type: "json" };
import a45 from "./app-definitions/supabase.json" with { type: "json" };
import a46 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a47 from "./app-definitions/asana.json" with { type: "json" };
import a48 from "./app-definitions/box.json" with { type: "json" };
import a49 from "./app-definitions/mem0.json" with { type: "json" };
import a50 from "./app-definitions/pagerduty.json" with { type: "json" };
import a51 from "./app-definitions/similarweb.json" with { type: "json" };
import a52 from "./app-definitions/xero.json" with { type: "json" };
import a53 from "./app-definitions/gmail.json" with { type: "json" };
import a54 from "./app-definitions/google-drive.json" with { type: "json" };
import a55 from "./app-definitions/google-docs.json" with { type: "json" };
import a56 from "./app-definitions/google-sheets.json" with { type: "json" };
import a57 from "./app-definitions/google-slides.json" with { type: "json" };
import a58 from "./app-definitions/google-calendar.json" with { type: "json" };
import a59 from "./app-definitions/google-chat.json" with { type: "json" };
import a60 from "./app-definitions/google-people.json" with { type: "json" };
import a61 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a3 from "./app-definitions/microsoft-teams.json" with { type: "json" };
import a4 from "./app-definitions/telegram.json" with { type: "json" };
import a5 from "./app-definitions/discord.json" with { type: "json" };
import a6 from "./app-definitions/notion.json" with { type: "json" };
import a7 from "./app-definitions/posthog.json" with { type: "json" };
import a8 from "./app-definitions/linear.json" with { type: "json" };
import a9 from "./app-definitions/context7.json" with { type: "json" };
import a10 from "./app-definitions/shopify.json" with { type: "json" };
import a11 from "./app-definitions/composio.json" with { type: "json" };
import a12 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a13 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a14 from "./app-definitions/sentry.json" with { type: "json" };
import a15 from "./app-definitions/vercel.json" with { type: "json" };
import a16 from "./app-definitions/anthropic.json" with { type: "json" };
import a17 from "./app-definitions/jira.json" with { type: "json" };
import a18 from "./app-definitions/airtable.json" with { type: "json" };
import a19 from "./app-definitions/beehiiv.json" with { type: "json" };
import a20 from "./app-definitions/bitly.json" with { type: "json" };
import a21 from "./app-definitions/candid.json" with { type: "json" };
import a22 from "./app-definitions/cloudflare.json" with { type: "json" };
import a23 from "./app-definitions/cloudinary.json" with { type: "json" };
import a24 from "./app-definitions/coda.json" with { type: "json" };
import a25 from "./app-definitions/hugging-face.json" with { type: "json" };
import a26 from "./app-definitions/kernel.json" with { type: "json" };
import a27 from "./app-definitions/local-falcon.json" with { type: "json" };
import a28 from "./app-definitions/make.json" with { type: "json" };
import a29 from "./app-definitions/manufact.json" with { type: "json" };
import a30 from "./app-definitions/miro.json" with { type: "json" };
import a31 from "./app-definitions/netlify.json" with { type: "json" };
import a32 from "./app-definitions/oreilly.json" with { type: "json" };
import a33 from "./app-definitions/planetscale.json" with { type: "json" };
import a34 from "./app-definitions/resend.json" with { type: "json" };
import a35 from "./app-definitions/ticktick.json" with { type: "json" };
import a36 from "./app-definitions/todoist.json" with { type: "json" };
import a37 from "./app-definitions/webflow.json" with { type: "json" };
import a38 from "./app-definitions/wix.json" with { type: "json" };
import a39 from "./app-definitions/brex.json" with { type: "json" };
import a40 from "./app-definitions/clickhouse.json" with { type: "json" };
import a41 from "./app-definitions/egnyte.json" with { type: "json" };
import a42 from "./app-definitions/embat.json" with { type: "json" };
import a43 from "./app-definitions/mixpanel.json" with { type: "json" };
import a44 from "./app-definitions/postman.json" with { type: "json" };
import a45 from "./app-definitions/razorpay.json" with { type: "json" };
import a46 from "./app-definitions/sanity.json" with { type: "json" };
import a47 from "./app-definitions/stripe.json" with { type: "json" };
import a48 from "./app-definitions/supabase.json" with { type: "json" };
import a49 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a50 from "./app-definitions/asana.json" with { type: "json" };
import a51 from "./app-definitions/box.json" with { type: "json" };
import a52 from "./app-definitions/mem0.json" with { type: "json" };
import a53 from "./app-definitions/pagerduty.json" with { type: "json" };
import a54 from "./app-definitions/similarweb.json" with { type: "json" };
import a55 from "./app-definitions/xero.json" with { type: "json" };
import a56 from "./app-definitions/gmail.json" with { type: "json" };
import a57 from "./app-definitions/google-drive.json" with { type: "json" };
import a58 from "./app-definitions/google-docs.json" with { type: "json" };
import a59 from "./app-definitions/google-sheets.json" with { type: "json" };
import a60 from "./app-definitions/google-slides.json" with { type: "json" };
import a61 from "./app-definitions/google-calendar.json" with { type: "json" };
import a62 from "./app-definitions/google-chat.json" with { type: "json" };
import a63 from "./app-definitions/google-people.json" with { type: "json" };
import a64 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64] as AppDefinition[];

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,9 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"google-people",
"google-workspace-search",
"github",
"discord",
"microsoft-teams",
"telegram",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
@ -54,7 +57,6 @@ export const APP_STORE_HIDDEN_SLUGS = new Set([
"razorpay",
"sanity",
"similarweb",
"slack",
"ticket-tailor",
"ticktick",
"xero",

View File

@ -0,0 +1,65 @@
{
"schemaVersion": 1,
"slug": "discord",
"name": "Discord",
"description": "Let people start and continue Paperclip work with an agent from Discord.",
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/discord.svg"
},
"urlPatterns": [
"https://discord.com/*"
],
"methods": [
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "discord",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in Discord start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "botToken",
"label": "Bot token",
"type": "password",
"required": true,
"placeholder": "Paste the Discord bot token",
"secret": true
},
{
"key": "applicationId",
"label": "Application ID",
"type": "text",
"required": true,
"placeholder": "123456789012345678",
"secret": false
},
{
"key": "guildId",
"label": "Server ID",
"type": "text",
"required": true,
"placeholder": "123456789012345678",
"secret": false
}
],
"guidanceMd": "Create one dedicated Discord application and bot, enable the Message Content intent, install it in one server with the documented bot permissions, then connect its bot token, Application ID, and server ID. Paperclip starts one Discord thread per root bot mention and keeps the linked Paperclip task authoritative.",
"consoleLinks": {
"register": "https://discord.com/developers/applications",
"docs": "https://discord.com/developers/docs/quick-start/getting-started"
},
"riskTier": "S3",
"requiredResourceFilters": [
"channel"
]
}
]
}

View File

@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "github",
"name": "GitHub",
"description": "Read code and pull requests, and coordinate repository work.",
"description": "Give agents repository tools or let people work with an agent from GitHub issues and pull requests.",
"categories": [
"developer"
],
@ -12,20 +12,14 @@
"darkLogoUrl": "/brands/apps/github-dark.svg"
},
"urlPatterns": [
"https://api.githubcopilot.com/mcp/*"
"https://api.githubcopilot.com/mcp/*",
"https://github.com/*"
],
"methods": [
{
"key": "managed",
"label": "Connect with GitHub",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "github.code",
"grantKinds": [
"user",
"agent"
],
"ownershipModes": [
"platform_shared"
],
@ -34,10 +28,18 @@
"serverUrl": "https://api.githubcopilot.com/mcp/"
},
"guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
"riskTier": "S3",
"label": "Use this connection as an agent tool",
"purpose": "tool",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "github.code",
"grantKinds": [
"user",
"agent"
],
"warnings": [
"Shell Git and gh receive this identity for the run and are not constrained by per-tool Ask-first controls."
],
"riskTier": "S3",
"requiredResourceFilters": [
"organization",
"repository"
@ -45,7 +47,6 @@
},
{
"key": "mcp-key",
"label": "Personal access token (advanced)",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
@ -57,6 +58,8 @@
},
"guidanceMd": "Create a fine-grained token limited to the repositories agents should use.",
"riskTier": "S3",
"label": "Personal access token (advanced)",
"purpose": "tool",
"credentialFields": [
{
"key": "authorization",
@ -76,6 +79,46 @@
"organization",
"repository"
]
},
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "github",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in GitHub start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "appId",
"label": "GitHub App ID",
"type": "text",
"required": true,
"placeholder": "123456",
"secret": false
},
{
"key": "privateKey",
"label": "Private key (PEM)",
"type": "textarea",
"required": true,
"placeholder": "-----BEGIN RSA PRIVATE KEY-----",
"secret": true
}
],
"guidanceMd": "Generate the webhook secret in Paperclip, then create one private GitHub App with active SSL-verified webhooks, Issues and Pull requests read/write permission, and the selectable issue_comment and pull_request_review_comment events. GitHub sends installation and installation_repositories automatically. Install the App only on repositories where people may mention the agent.",
"consoleLinks": {
"register": "https://github.com/settings/apps/new",
"docs": "https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app"
},
"riskTier": "S3",
"requiredResourceFilters": [
"organization",
"repository"
]
}
]
}

View File

@ -0,0 +1,57 @@
{
"schemaVersion": 1,
"slug": "microsoft-teams",
"name": "Microsoft Teams",
"description": "Let people start and continue Paperclip work with an agent from Microsoft Teams.",
"categories": ["communication"],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/microsoft-teams.svg"
},
"urlPatterns": ["https://teams.microsoft.com/*"],
"methods": [
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "microsoft-teams",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": ["customer"],
"whenToUse": "Let people in Microsoft Teams start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "clientId",
"label": "Application / Client ID",
"type": "text",
"required": true,
"placeholder": "00000000-0000-0000-0000-000000000000",
"secret": false
},
{
"key": "tenantId",
"label": "Directory / Tenant ID",
"type": "text",
"required": true,
"placeholder": "00000000-0000-0000-0000-000000000000",
"secret": false
},
{
"key": "clientSecret",
"label": "Client secret",
"type": "password",
"required": true,
"placeholder": "Paste the client-secret value",
"secret": true
}
],
"guidanceMd": "Use a Microsoft 365 work or school organization where you can register an Entra app, create a single-tenant Azure Bot, and upload or install a Teams app. Personal or free Teams accounts at teams.live.com cannot complete this setup. Enable personal, team, and groupChat bot scopes and the ChannelMessage.Read.Group and ChatMessage.Read.Chat resource-specific application permissions. Those RSC grants let an installed app receive every message in a team or group chat without an @mention, so explain that access to installers. One team install covers its standard channels; private and shared channels require a separate installation and are not supported by this release.",
"consoleLinks": {
"register": "https://dev.teams.microsoft.com/apps",
"docs": "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams"
},
"riskTier": "S3",
"requiredResourceFilters": ["team", "channel", "chat"]
}
]
}

View File

@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "slack",
"name": "Slack",
"description": "Search channels and coordinate team communication.",
"description": "Give agents Slack tools or let people start and continue Paperclip work from Slack.",
"categories": [
"communication"
],
@ -11,7 +11,8 @@
"logoUrl": "/brands/apps/slack.png"
},
"urlPatterns": [
"https://mcp.slack.com/*"
"https://mcp.slack.com/*",
"https://app.slack.com/client/*"
],
"methods": [
{
@ -34,6 +35,48 @@
},
"guidanceMd": "Connect a Slack workspace and limit access to the channels agents need.",
"riskTier": "S3",
"label": "Use this connection as an agent tool",
"purpose": "tool",
"requiredResourceFilters": [
"workspace",
"channel"
]
},
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "slack",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in Slack start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "botToken",
"label": "Bot User OAuth Token",
"type": "password",
"required": true,
"placeholder": "xoxb-...",
"secret": true
},
{
"key": "signingSecret",
"label": "Signing Secret",
"type": "password",
"required": true,
"placeholder": "Paste the Slack App signing secret",
"secret": true
}
],
"guidanceMd": "Create and install one Slack App for this agent. Paperclip receives verified Events API requests and interactive callbacks, acknowledges with reactions, responds in direct messages, and starts one Paperclip task per new mentioned channel thread.",
"consoleLinks": {
"register": "https://api.slack.com/apps",
"docs": "https://api.slack.com/start/quickstart"
},
"riskTier": "S3",
"requiredResourceFilters": [
"workspace",
"channel"

View File

@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "telegram",
"name": "Telegram",
"description": "Let people start and continue Paperclip work with an agent from Telegram.",
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/telegram.svg"
},
"urlPatterns": [
"https://t.me/*",
"https://telegram.me/*",
"https://api.telegram.org/*"
],
"methods": [
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "telegram",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in Telegram start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "botToken",
"label": "Bot token",
"type": "password",
"required": true,
"placeholder": "123456789:AA...",
"secret": true
}
],
"guidanceMd": "Create one dedicated bot with BotFather, then connect its token to the public Paperclip webhook endpoint.",
"consoleLinks": {
"register": "https://t.me/BotFather",
"docs": "https://core.telegram.org/bots/tutorial"
},
"riskTier": "S3",
"requiredResourceFilters": [
"chat",
"group",
"topic"
]
}
]
}

View File

@ -363,6 +363,7 @@ export const ISSUE_ORIGIN_KINDS = [
"task_watchdog",
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
"chat_channel",
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
@ -1023,7 +1024,7 @@ export const PERMISSION_KEYS = [
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
export const TOOL_APPLICATION_TYPES = ["mcp_http", "mcp_stdio", "paperclip_plugin", "a2a"] as const;
export const TOOL_APPLICATION_TYPES = ["mcp_http", "mcp_stdio", "paperclip_plugin", "a2a", "chat"] as const;
export type ToolApplicationType = (typeof TOOL_APPLICATION_TYPES)[number];
export const TOOL_APPLICATION_STATUSES = ["draft", "active", "disabled", "archived"] as const;

View File

@ -100,6 +100,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: true,
selfHostedDefault: true,
},
enableChatConnectors: {
title: "Chat connectors",
description:
"Show experimental chat connector setup and Board surfaces. Existing connections keep running when hidden; GitHub and other tool connectors are unaffected.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enablePipelines: {
title: "Pipelines",
description: "Enable pipeline definitions and pipeline-driven case production surfaces.",

View File

@ -341,9 +341,9 @@ export {
SELF_SERVE_MCP_RESEARCH,
} from "./self-serve-mcp-research.js";
export * from "./validators/status-card.js";
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
export * from "./types/chat-channels.js";
export * from "./validators/chat-channels.js";
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
export {
humanizeConnectionDisplayName,
connectionDisplaySecondaryHint,
@ -1254,6 +1254,7 @@ export type {
FinanceByBiller,
FinanceByKind,
AgentWakeupResponse,
ChatFailedRunRetryResponse,
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
@ -2148,6 +2149,7 @@ export {
toolCatalogEntryStatusSchema,
toolConnectionHealthStatusSchema,
toolConnectionKindSchema,
toolConnectionPurposeSchema,
toolConnectionTransportSchema,
toolConnectionStatusSchema,
toolCredentialPlacementSchema,

View File

@ -17,18 +17,18 @@ types as the final authority for emitted first-party telemetry shapes.
Use these files when reviewing or changing telemetry code:
| Contract item | Public source |
| --- | --- |
| First-party event names | `PaperclipEventName` in `generated/paperclip-telemetry.ts` |
| Per-event dimensions and optionality | `EventDimensionsMap` in `generated/paperclip-telemetry.ts` |
| Enum descriptions for telemetry dimensions | `PAPERCLIP_ENUM_DESCRIPTIONS` in `generated/paperclip-telemetry.ts` |
| Schema version and event envelope helpers | `SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()` in `generated/paperclip-telemetry.ts` |
| Runtime-safe event names and dimensions | `TelemetryEventName` and `TelemetryEventDimensions` in `types.ts` |
| Allowed primitive dimension values | `TelemetryDimensionValue` in `types.ts` |
| Shared reusable enum domains | Named exports in `constants.ts` |
| First-party typed emit helpers | `events.ts` |
| Generic client behavior | `client.ts` |
| Retention windows and event class assignments | `RETENTION_DAYS` and `EVENT_RETENTION_CLASS` in `retention.ts` |
| Contract item | Public source |
| --------------------------------------------- | ---------------------------------------------------------------------------------------- |
| First-party event names | `PaperclipEventName` in `generated/paperclip-telemetry.ts` |
| Per-event dimensions and optionality | `EventDimensionsMap` in `generated/paperclip-telemetry.ts` |
| Enum descriptions for telemetry dimensions | `PAPERCLIP_ENUM_DESCRIPTIONS` in `generated/paperclip-telemetry.ts` |
| Schema version and event envelope helpers | `SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()` in `generated/paperclip-telemetry.ts` |
| Runtime-safe event names and dimensions | `TelemetryEventName` and `TelemetryEventDimensions` in `types.ts` |
| Allowed primitive dimension values | `TelemetryDimensionValue` in `types.ts` |
| Shared reusable enum domains | Named exports in `constants.ts` |
| First-party typed emit helpers | `events.ts` |
| Generic client behavior | `client.ts` |
| Retention windows and event class assignments | `RETENTION_DAYS` and `EVENT_RETENTION_CLASS` in `retention.ts` |
Do not copy generated event lists or dimension tables into this README. They
will drift as the generated contract changes.
@ -57,6 +57,14 @@ If a dimension is privacy-protected before emission, emit only the protected
value and its matching public marker as defined by the typed helper or generated
contract. Do not emit private source material in telemetry dimensions.
Credential-bearing chat setup failures replace provider-controlled error names,
messages, and stacks with a fresh generic error before the HTTP error handler
reports the crash. The existing `error.handler_crash` event still uses its
generated `error_code: string` contract; this path emits only `Error`, never a
provider-supplied error name that may contain a credential. This is a privacy
boundary, not enum canonicalization. Test the value reaching the telemetry
helper as well as the separate crash-reporting and local logging sinks.
## Interaction Resolver Events
`interaction.created` records the interaction kind and whether the create
@ -180,8 +188,8 @@ concern — updating a retention window does not require a schema version bump.
Current classes:
| Class | Window | Description |
| --- | --- | --- |
| Class | Window | Description |
| ------------------------ | ------- | ------------------------------------------------------------ |
| `operational_enum_count` | 90 days | Enum/boolean/count/bucket events. No token material, no PII. |
When a new event carries only enums, booleans, counts, or coarse buckets and

View File

@ -1,8 +1,8 @@
import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionTransport, VercelConnectPrincipalMode } from "./tool-access.js";
import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpose, ToolConnectionTransport, VercelConnectPrincipalMode } from "./tool-access.js";
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface ConnectionMethodDef { key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export type SelfServeMcpAuthMode =

View File

@ -18,12 +18,7 @@ export type ProviderTraceDisposition =
"mapped" | "generic" | "ignored" | "rejected" | "operator_only";
export type ProviderTraceFieldMappingAction =
| "copied"
| "renamed"
| "normalized"
| "derived"
| "dropped"
| "redacted";
"copied" | "renamed" | "normalized" | "derived" | "dropped" | "redacted";
export interface ProviderTraceFieldMapping {
inputPath?: string;
@ -169,9 +164,23 @@ export interface HeartbeatRun {
responsibleUserId: string | null;
activeIdentityContextId?: string | null;
identityHistory?: Array<{
id: string; revision: number; responsibleUserId: string | null; messageId: string | null;
parentContextId: string | null; cause: string; status: string; acceptedAt: Date | string | null;
github: { status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string; connectionId?: string; grantId?: string; authenticationMode?: "managed" | "host" | "anonymous" } | null;
id: string;
revision: number;
responsibleUserId: string | null;
messageId: string | null;
parentContextId: string | null;
cause: string;
status: string;
acceptedAt: Date | string | null;
github: {
status: "available" | "absent" | "unavailable";
login?: string;
source?: "personal" | "dedicated";
reason?: string;
connectionId?: string;
grantId?: string;
authenticationMode?: "managed" | "host" | "anonymous";
} | null;
}>;
startedAt: Date | null;
finishedAt: Date | null;
@ -271,6 +280,15 @@ export interface AgentWakeupSkipped {
export type AgentWakeupResponse = HeartbeatRun | AgentWakeupSkipped;
/** A durable chat retry can be accepted before a scheduler run exists. */
export interface ChatFailedRunRetryResponse {
actionId: string;
issueId: string;
runId: string | null;
status:
"queued" | "deferred" | "running" | "succeeded" | "failed" | "cancelled";
}
export interface HeartbeatRunEvent {
id: number;
companyId: string;

View File

@ -880,6 +880,7 @@ export type { CostEvent, CostSummary, IssueCostSummary, CostByAgent, CostByProvi
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
export type {
AgentWakeupResponse,
ChatFailedRunRetryResponse,
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
@ -1064,3 +1065,4 @@ export type {
PluginDatabaseNamespaceStatus,
} from "./plugin.js";
export * from "./app-definition.js";
export * from "./chat-channels.js";

View File

@ -62,6 +62,8 @@ export interface InstanceExperimentalSettings {
enableStreamlinedUi: boolean;
/** @deprecated Compatibility key only. Apps is always enabled. */
enableApps: boolean;
/** Exposes chat connector setup and Board surfaces; existing delivery continues when hidden. */
enableChatConnectors: boolean;
enablePipelines: boolean;
enableCases: boolean;
enableConferenceRoomChat: boolean;

View File

@ -862,6 +862,8 @@ export interface Issue {
goal?: Goal | null;
currentExecutionWorkspace?: ExecutionWorkspace | null;
workProducts?: IssueWorkProduct[];
/** Present when this task is the durable counterpart of an external chat conversation. */
externalChannelBinding?: import("./chat-channels.js").ExternalChannelBindingSummary | null;
mentionedProjects?: Project[];
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;
@ -1569,6 +1571,8 @@ export interface IssueAttachment {
companyId: string;
issueId: string;
issueCommentId: string | null;
/** Immutable run attribution recorded when an agent uploads the attachment. */
originatingRunId?: string | null;
assetId: string;
provider: string;
objectKey: string;

View File

@ -67,16 +67,21 @@ export type {
};
export type ToolActorType = "agent" | "user" | "system" | "plugin";
export type ToolConnectionTransport = "mcp_remote" | "rest_api" | "local_stdio";
export type ToolConnectionTransport =
"mcp_remote" | "rest_api" | "local_stdio" | "chat_sdk";
export type ToolConnectionPurpose = "tool" | "channel";
export type ToolConnectionAuthKind = "oauth" | "api_key" | "none";
export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned" | "customer" | "dcr";
export type ToolConnectionCredentialSource = "paperclip_vault" | "vercel_connect";
export type ToolConnectionOwnership =
"platform_shared" | "platform_provisioned" | "customer" | "dcr";
export type ToolConnectionCredentialSource =
"paperclip_vault" | "vercel_connect";
export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived";
export type ToolConnectionInstallTargetType = "company" | "agent";
export type ConnectionGrantKind = "organization" | "user" | "agent";
export type ConnectionGrantStatus = "active" | "revoked" | "expired" | "needs_reauthorization";
export type ToolConnectionCredentialPolicy = "shared" | "per_user" | "per_user_with_fallback" | "per_agent";
export type ConnectionGrantStatus =
"active" | "revoked" | "expired" | "needs_reauthorization";
export type ToolConnectionCredentialPolicy =
"shared" | "per_user" | "per_user_with_fallback" | "per_agent";
export type ConnectionGrantMemberSubjectType = "user";
export type ToolCredentialPlacement = "header" | "env" | "url";
@ -166,6 +171,7 @@ export interface ToolConnection {
name: string;
uid: string;
connectionKind: ToolConnectionKind;
connectionPurpose: ToolConnectionPurpose;
ownership: ToolConnectionOwnership;
transport: ToolConnectionTransport;
authKind: ToolConnectionAuthKind;
@ -228,7 +234,12 @@ export interface ConnectionGrant {
installationIds: string[];
installationOwnerLogins: string[];
/** Repository metadata visible to this credential; refreshed from GitHub. */
repositories?: Array<{ id: string; fullName: string; installationId: string; private?: boolean }>;
repositories?: Array<{
id: string;
fullName: string;
installationId: string;
private?: boolean;
}>;
installationUrl?: string;
managementUrl?: string;
appSlug?: string;
@ -396,7 +407,8 @@ export interface ToolConnectionRemovalResult {
}
export type ConnectionTokenScope = string | string[];
export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: string };
export type ConnectionTokenSubject =
{ type: "app" } | { type: "user"; userId: string };
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"user_authorization_required",
@ -411,7 +423,8 @@ export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"standing_delegation_required",
] as const;
export type ConnectionRecoverableErrorCode = typeof CONNECTION_RECOVERABLE_ERROR_CODES[number];
export type ConnectionRecoverableErrorCode =
(typeof CONNECTION_RECOVERABLE_ERROR_CODES)[number];
export interface ConnectionRecoverableErrorPayload {
code: ConnectionRecoverableErrorCode;
@ -464,7 +477,8 @@ export interface ConnectionTokenUseEnvLeaseResponse {
attribution: ConnectionTokenAttribution;
}
export type ConnectionTokenResponse = ConnectionTokenMintedResponse | ConnectionTokenUseEnvLeaseResponse;
export type ConnectionTokenResponse =
ConnectionTokenMintedResponse | ConnectionTokenUseEnvLeaseResponse;
export interface StartConnectionAuthorizationRequest {
subjectUserId: string;
@ -478,7 +492,11 @@ export interface StartConnectionAuthorizationResponse {
export interface ConnectionUsageDailyBucket {
date: string;
issuances: { total: number; byOutcome: Record<string, number>; byPath: Record<string, number> };
issuances: {
total: number;
byOutcome: Record<string, number>;
byPath: Record<string, number>;
};
invocations: { total: number; byRiskLevel: Record<string, number> };
deliveries: { received: number; forwarded: number };
}
@ -1007,16 +1025,22 @@ export interface ToolConnectionActivityResponse {
connectionId: string;
events: ToolCallEvent[];
lifecycleEvents: ToolConnectionLifecycleEvent[];
issues: Record<string, {
identifier: string;
title: string;
}>;
actionRequests: Record<string, {
status: ToolActionRequestStatus;
resolverDisplayName: string | null;
resolvedByAgentId: string | null;
resolvedByUserId: string | null;
}>;
issues: Record<
string,
{
identifier: string;
title: string;
}
>;
actionRequests: Record<
string,
{
status: ToolActionRequestStatus;
resolverDisplayName: string | null;
resolvedByAgentId: string | null;
resolvedByUserId: string | null;
}
>;
}
/**
@ -1126,7 +1150,8 @@ export interface ToolAppConnectionActionSummary {
* that issuer, then a Client ID Metadata Document, then dynamic registration,
* then client credentials the operator preregistered and pasted in.
*/
export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr" | "manual";
export type ToolOAuthClientRegistrationSource =
"preconfigured" | "cimd" | "dcr" | "manual";
/** Opaque managed-Cloud exchange; clients never treat the session as a URL. */
export interface ToolOAuthHandoff {
@ -1456,7 +1481,15 @@ export interface ToolPolicyConditions {
isWrite?: boolean;
isDestructive?: boolean;
};
credentialScope?: Pick<ToolAccessSelector, "applicationId" | "applicationIds" | "connectionId" | "connectionIds" | "catalogEntryId" | "catalogEntryIds"> & {
credentialScope?: Pick<
ToolAccessSelector,
| "applicationId"
| "applicationIds"
| "connectionId"
| "connectionIds"
| "catalogEntryId"
| "catalogEntryIds"
> & {
applicationKey?: string;
applicationKeys?: string[];
providerType?: string;
@ -1648,12 +1681,7 @@ export interface ToolConnectionTestCallResult {
* - `expired` the approval window lapsed.
*/
export type ToolConnectionTestCallStatusPhase =
| "waiting"
| "running"
| "done"
| "denied"
| "cancelled"
| "expired";
"waiting" | "running" | "done" | "denied" | "cancelled" | "expired";
/** Live status of an ask-first test call (`GET /tool-connections/:id/test-calls/:actionRequestId`). */
export interface ToolConnectionTestCallStatus {

Some files were not shown because too many files have changed in this diff Show More