Merge current master into session compatibility

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 14:24:41 -05:00
commit 53a8deca4e
1323 changed files with 1488292 additions and 52445 deletions

View File

@ -0,0 +1,50 @@
// Run before building, and again inside the protected deployment job on reruns.
module.exports = async function authorizeStorybookDeploy({ github, context }) {
const fail = (message) => { throw new Error(message); };
if (context.repo.owner !== "paperclipai" || context.repo.repo !== "paperclip") {
fail("Storybook publishing is restricted to paperclipai/paperclip.");
}
if (context.eventName !== "workflow_dispatch" || !context.ref.startsWith("refs/heads/")) {
fail("Storybook publishing requires a manual run from a repository branch.");
}
// The selected branch must never be able to add itself to the allowlist.
const { data: repository } = await github.rest.repos.get(context.repo);
const { data: file } = await github.rest.repos.getContent({
...context.repo,
path: ".github/CODEOWNERS",
ref: repository.default_branch,
});
if (file.encoding !== "base64" || typeof file.content !== "string") {
fail("Cannot read the default branch CODEOWNERS file.");
}
const owners = new Set();
for (const line of Buffer.from(file.content, "base64").toString("utf8").split(/\r?\n/)) {
const fields = line.split("#", 1)[0].trim().split(/\s+/);
for (const owner of fields.slice(1)) {
// Individual GitHub accounts only. Teams/email entries do not grant access.
if (/^@[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner)) {
owners.add(owner.slice(1).toLowerCase());
}
}
}
if (owners.size === 0) fail("CODEOWNERS has no individual GitHub accounts.");
for (const actor of [context.actor, process.env.GITHUB_TRIGGERING_ACTOR]) {
if (!actor || !owners.has(actor.toLowerCase())) {
fail(`Only default-branch CODEOWNERS may publish Storybook (${actor || "missing actor"}).`);
}
}
// A branch can edit its workflow. Require a GitHub-enforced CODEOWNER review
// as well, so editing this check cannot grant an outsider deployment access.
const { data: environment } = await github.rest.repos.getEnvironment({
...context.repo,
environment_name: "storybook-deploy",
});
const reviewers = environment.protection_rules
?.find((rule) => rule.type === "required_reviewers")?.reviewers;
if (environment.can_admins_bypass !== false || !reviewers?.length ||
reviewers.some(({ type, reviewer }) => type !== "User" || !owners.has(reviewer.login.toLowerCase()))) {
fail("storybook-deploy must require CODEOWNER reviewers and disable administrator bypass.");
}
};

46
.github/scripts/publish-storybook.cjs vendored Normal file
View File

@ -0,0 +1,46 @@
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const { storybookDestination, branchIndex } = require('./storybook-destination.cjs');
const destination = storybookDestination({
branch: process.env.SOURCE_BRANCH, sha: process.env.SOURCE_SHA,
runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL,
});
const source = path.resolve('storybook-static');
// Treat the artifact as public files, never as executable publisher code.
function validateTree(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isSymbolicLink() || entry.name.startsWith('.') || (!entry.isDirectory() && !entry.isFile())) {
throw new Error(`Unsupported artifact entry: ${path.join(dir, entry.name)}`);
}
if (entry.isDirectory()) validateTree(path.join(dir, entry.name));
}
}
validateTree(source);
for (const name of ['index.html', 'iframe.html', 'index.json']) {
if (!fs.statSync(path.join(source, name)).isFile() || fs.statSync(path.join(source, name)).size === 0) {
throw new Error(`Missing Storybook output: ${name}`);
}
}
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n');
const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' });
// Complete a unique build before changing the branch's entry point. No deletion
// permissions, shared root writes or mixed-version branch assets are needed.
aws(['s3', 'cp', source, `s3://${destination.bucket}/${destination.buildPrefix}/`,
'--recursive', '--no-follow-symlinks', '--only-show-errors',
'--cache-control', 'public,max-age=31536000,immutable']);
const indexFile = path.join(process.env.RUNNER_TEMP, 'storybook-branch-index.html');
fs.writeFileSync(indexFile, branchIndex(destination.buildUrl));
aws(['s3', 'cp', indexFile, `s3://${destination.bucket}/${destination.prefix}/index.html`,
'--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache,max-age=0,must-revalidate', '--only-show-errors']);
aws(['s3', 'cp', indexFile, `s3://${destination.bucket}/${destination.bookmarkPrefix}/index.html`,
'--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache,max-age=0,must-revalidate', '--only-show-errors']);
const report = `[Branch Storybook](${destination.url})\n\n[This build](${destination.buildUrl})\n\nCommit: \`${destination.sha}\`\n`;
const reportPath = path.join(process.env.RUNNER_TEMP, 'storybook-deployment.md');
fs.writeFileSync(reportPath, report);
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`url=${destination.url}\nbuild_url=${destination.buildUrl}\nreport_path=${reportPath}\n`);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
console.log(JSON.stringify(destination));

View File

@ -0,0 +1,47 @@
const { createHash } = require('node:crypto');
function storybookDestination({ branch, sha, runId, runAttempt, bucket, baseUrl }) {
if (typeof branch !== 'string' || !branch || /[\x00-\x20\x7f]/.test(branch)) {
throw new Error('A non-empty repository branch name is required.');
}
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.');
if (![runId, runAttempt].every((value) => /^[1-9]\d*$/.test(String(value)))) {
throw new Error('A valid workflow run and attempt are required.');
}
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) throw new Error('Invalid Storybook S3 bucket.');
const base = new URL(baseUrl);
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') {
throw new Error('Storybook base URL must be a credential-free HTTPS origin.');
}
const label = branch.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0,60) || 'branch';
const digest = createHash('sha256').update(branch).digest('hex').slice(0,16);
const branchKey = `${label}-${digest}`;
const prefix = `storybook/branches/${branchKey}`;
const buildPrefix = `${prefix}/builds/${runId}-${runAttempt}`;
// Use one reversible path segment: slashes and special characters become
// ~HH UTF-8 bytes, so feature/foo and feature-foo never share a bookmark.
let bookmarkKey = [...Buffer.from(branch)].map((byte) =>
/[A-Za-z0-9_-]/.test(String.fromCharCode(byte))
? String.fromCharCode(byte) : `~${byte.toString(16).toUpperCase().padStart(2, '0')}`).join('');
// Reserve the existing hashed directories, including all immutable builds.
bookmarkKey = bookmarkKey.replace(/-([a-f0-9]{16})$/, '~2D$1');
// Keep arbitrarily long ref names within S3's object-key limit. ~long cannot
// occur in the reversible encoding, whose escapes contain only hex digits.
if (bookmarkKey.length > 900) bookmarkKey = `${bookmarkKey.slice(0, 800)}~long-${digest}`;
const bookmarkPrefix = `storybook/branches/${bookmarkKey}`;
return {
branch, sha, bucket, branchKey, prefix, buildPrefix, bookmarkPrefix,
url: `${base.origin}/${bookmarkPrefix}/`,
legacyUrl: `${base.origin}/${prefix}/index.html`,
buildUrl: `${base.origin}/${buildPrefix}/index.html`,
};
}
function branchIndex(buildUrl) {
// The target is generated from a validated origin and ASCII path segments.
const target = JSON.stringify(buildUrl).replace(/</g, '\\u003c');
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Storybook preview</title>
<script>const target = new URL(${target}); target.search = location.search; target.hash = location.hash; location.replace(target.href);</script>
<noscript><a href="${buildUrl}">Open this branch's Storybook</a></noscript></html>\n`;
}
module.exports = { storybookDestination, branchIndex };

View File

@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { waitForCloudArtifacts } from "../../../scripts/cloud-readiness.mjs";
import { previewManifest } from "../../../scripts/preview-artifacts.mjs";
const sha = "a".repeat(40);
const digest = `sha256:${"b".repeat(64)}`;
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
function registry({ missing = new Set(), failure, wrongImage = false, wrongPackage = false } = {}) {
return async (url) => {
if (failure) return json({}, failure);
if (url.startsWith("https://registry.npmjs.org/")) {
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
if (missing.has(name.split("/")[1])) return json({}, 404);
const pkg = previewManifest({ name, version: "0.0.0" }, sha);
return json({ ...pkg, ...(wrongPackage ? { gitHead: "c".repeat(40) } : {}), dist: { integrity: "sha512-fixture", tarball: "https://registry.npmjs.org/fixture.tgz" } });
}
if (url.includes("/token?")) return json({ token: "fixture" });
if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } });
if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } });
throw new Error(`Unexpected request: ${url}`);
};
}
test("readiness requires the image and both exact-source packages on the successful poll", async () => {
const missing = new Set(["image", "shared", "db"]);
let clock = 0;
const states = [];
const result = await waitForCloudArtifacts(sha, {
fetchImpl: registry({ missing }), now: () => clock, intervalMs: 10, timeoutMs: 100, log: (message) => states.push(message),
sleep: async (ms) => {
clock += ms;
if (clock === 10) missing.delete("image");
if (clock === 20) missing.delete("shared");
if (clock === 30) { missing.delete("db"); missing.add("image"); }
if (clock === 40) missing.delete("image");
},
});
assert.equal(clock, 40, "an artifact disappearing before the final poll must prevent readiness");
assert.deepEqual(result, { version: 1, sha, packageVersion: `0.0.0-preview.g${sha}` });
assert.match(states.at(-1), /Cloud artifacts available/);
});
test("missing artifacts time out with a precise inventory and bounded sleep", async () => {
let clock = 0;
const sleeps = [];
await assert.rejects(waitForCloudArtifacts(sha, {
fetchImpl: registry({ missing: new Set(["db"]) }), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {},
sleep: async (ms) => { sleeps.push(ms); clock += ms; },
}), /timed out.*missing: db/);
assert.deepEqual(sleeps, [20, 5]);
});
for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, { wrongPackage: true }]) {
test(`registry errors and identity mismatches fail without waiting: ${JSON.stringify(fixture)}`, async () => {
await assert.rejects(waitForCloudArtifacts(sha, {
fetchImpl: registry(fixture), sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {},
}));
});
}
test("invalid source and timing configuration are rejected before registry access", async () => {
const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry");
await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/);
for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) {
await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
}
});
test("the versioned readiness job requires successful source, image and artifact jobs", () => {
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
assert.match(workflow, /group: cloud-readiness-\$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
const ready = workflow.split(" ready:")[1];
assert.match(ready, /name: Cloud deployable v1/);
assert.match(ready, /needs: \[verify, image, artifacts\]/);
assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m);
assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
const cloud = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
assert.doesNotMatch(cloud, /^ push:/m, "the master image must build only once");
const migrator = readFileSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url), "utf8");
assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
assert.match(migrator, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
assert.match(migrator, /gh workflow run release.yml .*--ref master/);
assert.match(migrator, /--field channel=cloud-migrator/);
assert.match(migrator, /--field source_ref="\$SOURCE_SHA"/);
});

View File

@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
// Exercise the workflow's actual boolean expression. Its string comparisons and
// boolean operators have the same results in JS for these canonical contexts.
const expression = workflow.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(expression, "cloud routing must remain an explicit job expression");
const timeoutExpression = workflow.match(/^ timeout-minutes: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(timeoutExpression, "AWS jobs must finish before the Fleet instance lifetime");
const fleet = "runs-on/fleet=paperclip-cloud-build-x64/env=public-ci";
const base = { repository: "paperclipai/paperclip", repository_id: "1170821064", ref: "refs/heads/master", event_name: "push" };
for (const { name, github = {}, enabled = "true", expected = "ubuntu-latest" } of [
{ name: "canonical master push", expected: fleet },
{ name: "manual master build", github: { event_name: "workflow_dispatch" }, expected: fleet },
{ name: "disabled switch", enabled: "false" },
{ name: "missing switch", enabled: "" },
{ name: "invalid switch", enabled: "yes" },
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
{ name: "wrong repository identity", github: { repository_id: "123" } },
{ name: "pull request", github: { event_name: "pull_request", ref: "refs/pull/123/merge" } },
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
{ name: "branch push", github: { ref: "refs/heads/feature" } },
{ name: "manual branch build", github: { event_name: "workflow_dispatch", ref: "refs/heads/feature" } },
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
]) {
test(`cloud runner routing: ${name}`, () => {
const context = { github: { ...base, ...github }, vars: { AWS_CLOUD_BUILDS_ENABLED: enabled } };
assert.equal(runInNewContext(expression, context), expected);
assert.equal(runInNewContext(timeoutExpression, context), expected === fleet ? 40 : 60);
});
}

View File

@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
const step = workflow.split(" - name: Free runner disk")[1].split(" - name: Login to GitHub Container Registry")[0];
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const threshold = 64 * 1024 * 1024;
for (const { name, dockerFree, workspaceFree, dfStatus = "0", infoStatus = "0", cleanup } of [
{ name: "ample free space", dockerFree: threshold + 1, workspaceFree: threshold + 1, cleanup: false },
{ name: "exactly the headroom threshold", dockerFree: threshold, workspaceFree: threshold, cleanup: false },
{ name: "Docker filesystem below threshold", dockerFree: threshold - 1, workspaceFree: threshold + 1, cleanup: true },
{ name: "workspace filesystem below threshold", dockerFree: threshold + 1, workspaceFree: threshold - 1, cleanup: true },
{ name: "invalid Docker measurement", dockerFree: "unknown", workspaceFree: threshold + 1, cleanup: true },
{ name: "invalid workspace measurement", dockerFree: threshold + 1, workspaceFree: "unknown", cleanup: true },
{ name: "failed df command", dockerFree: threshold + 1, workspaceFree: threshold + 1, dfStatus: "1", cleanup: true },
{ name: "failed Docker inspection", dockerFree: threshold + 1, workspaceFree: threshold + 1, infoStatus: "1", cleanup: true },
]) {
test(`cloud disk cleanup: ${name}`, () => {
const dir = mkdtempSync(path.join(tmpdir(), "cloud-disk-test-"));
const log = path.join(dir, "commands.log");
// Every mutating command is a recording fixture; no real SDKs, caches,
// images, or directories are deleted when the workflow shell executes.
const fixture = `#!/bin/bash
printf '%s %s\\n' "\${0##*/}" "$*" >> "$COMMAND_LOG"
case "\${0##*/}" in
df)
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'
if [ "$1" = '-Pk' ]; then
printf '/dev/docker 200000000 1 %s 1%% /docker\\n' "$DOCKER_FREE"
printf '/dev/workspace 200000000 1 %s 1%% /workspace\\n' "$WORKSPACE_FREE"
exit "$DF_STATUS"
fi
;;
docker)
if [ "$1" = 'info' ]; then
printf '/docker-data\\n'
exit "$INFO_STATUS"
fi
;;
esac
`;
try {
for (const command of ["df", "docker", "pnpm", "sudo"]) {
writeFileSync(path.join(dir, command), fixture, { mode: 0o755 });
}
const result = spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_WORKSPACE: "/workspace", COMMAND_LOG: log,
DOCKER_FREE: String(dockerFree), WORKSPACE_FREE: String(workspaceFree), DF_STATUS: dfStatus, INFO_STATUS: infoStatus },
});
assert.equal(result.status, 0, result.stderr);
const commands = readFileSync(log, "utf8");
if (infoStatus === "0") assert.match(commands, /df -Pk \/docker-data \/workspace/);
assert.equal(commands.includes("pnpm store prune"), cleanup);
assert.equal(commands.includes("sudo rm -rf /usr/share/dotnet"), cleanup);
assert.equal(commands.includes("docker system prune -af"), cleanup);
assert.equal(result.stdout.includes("skipping cleanup"), !cleanup);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
}

View File

@ -6,6 +6,7 @@ const workflows = [
'.github/workflows/refresh-lockfile.yml',
'.github/workflows/pr-trusted.yml',
'.github/workflows/docker.yml',
'.github/workflows/docker-cloud.yml',
];
test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => {

View File

@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8");
const runner = workflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
test("Runner dependency caching selects the package's pinned compiler before computing its key", () => {
const select = runner.indexOf(" - name: Select the pinned Runner Rust toolchain");
const cache = runner.indexOf(" - name: Cache Runner Rust dependencies");
assert.ok(select >= 0 && cache > select);
const setup = runner.slice(select, cache);
assert.match(setup, /working-directory: packages\/paperclip-runner/);
assert.match(setup, /rustup show active-toolchain/);
assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
assert.match(runner, /uses: Swatinem\/rust-cache@[0-9a-f]{40} # v[0-9.]+/);
assert.match(runner, /workspaces: packages\/paperclip-runner\/runner -> target/);
assert.match(runner, /shared-key: release-runner-v1/);
});
test("the shared cache excludes workspace artifacts and only restores or saves the exact master-push source", () => {
assert.match(runner, /cache-workspace-crates: false/);
assert.match(runner, /cache-bin: false/);
const saveIf = runner.match(/^\s*save-if: (.+)$/m)?.[1];
assert.equal(saveIf, "${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}");
const cacheStep = runner.split(" - name: Cache Runner Rust dependencies")[1].split(" - name: Install dependencies")[0];
assert.equal(cacheStep.match(/^\s*if: (.+)$/m)?.[1], saveIf);
assert.doesNotMatch(runner, /cache-on-failure: true|cache-all-crates: true/);
});
test("cache hits cannot bypass Runner verification", () => {
const verify = runner.split(" - name: Verify Paperclip Runner")[1];
assert.match(verify, /run: pnpm --filter @paperclipai\/paperclip-runner check:all/);
assert.doesNotMatch(verify, /if:|continue-on-error:/);
assert.ok(runner.indexOf("Cache Runner Rust dependencies") < runner.indexOf(" - name: Verify Paperclip Runner\n"));
assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/);
});

26
.github/scripts/verify-storybook.cjs vendored Normal file
View File

@ -0,0 +1,26 @@
const { branchIndex } = require('./storybook-destination.cjs');
async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const [metadata, index] = await Promise.all([
fetch(new URL('deployment.json', buildUrl), { signal: AbortSignal.timeout(15000) }),
fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
]);
if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
return;
} catch (error) {
if (attempt === attempts) throw error;
await sleep(10000);
}
}
}
module.exports = { verifyStorybook };
if (require.main === module) {
verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
}

View File

@ -0,0 +1,10 @@
{
"Sid": "AllowCloudFrontReadStorybook",
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*",
"Condition": {"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::078455283791:distribution/E3GTU28BBO2SFR"
}}
}

View File

@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::078455283791:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:paperclipai/paperclip:environment:storybook-deploy"
}}
}]
}

View File

@ -0,0 +1,8 @@
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*"
}]
}

34
.github/workflows/cloud-artifacts.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Cloud artifacts
on:
push:
branches: [master]
workflow_dispatch:
permissions: {}
jobs:
dispatch_migrator:
name: Start exact-source cloud migrator publication
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
# This separate workflow starts at merge, outside the full npm release's
# concurrency group. Publication stays in release.yml so npm recognizes
# the established trusted-publisher identity and npm-canary environment.
# No source checkout or package code runs with the dispatch credential.
- name: Dispatch the migrator-only release
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
request_id="$(cat /proc/sys/kernel/random/uuid)"
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref master \
--field channel=cloud-migrator \
--field source_ref="$SOURCE_SHA" \
--field request_id="$request_id"
echo "Started Cloud migrator $SOURCE_SHA in release.yml (request $request_id)." >> "$GITHUB_STEP_SUMMARY"

93
.github/workflows/cloud-readiness.yml vendored Normal file
View File

@ -0,0 +1,93 @@
name: Cloud readiness
run-name: Cloud readiness ${{ github.sha }}
on:
push:
branches: [master]
workflow_dispatch:
permissions: {}
# Source verification must start outside the full npm release's queue.
concurrency:
group: cloud-readiness-${{ github.sha }}
cancel-in-progress: false
jobs:
image:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-cloud.yml
verify:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
contents: read
uses: ./.github/workflows/release-verify.yml
with:
ref: ${{ github.sha }}
artifacts:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
name: Wait for exact-source cloud artifacts
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Wait for verified image and exact-source migrator
env:
SOURCE_SHA: ${{ github.sha }}
run: node scripts/cloud-readiness.mjs "$SOURCE_SHA"
source_verified:
# npm canary publication reuses this exact-source verification proof.
# Keep it independent of image/migrator availability, and fail closed when
# any source check fails, is cancelled, or is skipped.
name: Cloud source verified v1
needs: [verify]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Check the source verification consumer
run: node --test scripts/cloud-source-verification.test.mjs
- name: Record source verification
env:
SOURCE_SHA: ${{ github.sha }}
run: |
echo "Cloud source verified v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
ready:
# Versioned consumer contract. Never add always() or continue-on-error:
# failed, cancelled, or skipped prerequisites must not report readiness.
name: Cloud deployable v1
needs: [verify, image, artifacts]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record cloud readiness
env:
SOURCE_SHA: ${{ github.sha }}
run: |
echo "Cloud deployable v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
echo "Source verification passed; the full-SHA image and exact-source migrator are available." >> "$GITHUB_STEP_SUMMARY"
echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." >> "$GITHUB_STEP_SUMMARY"

300
.github/workflows/docker-cloud.yml vendored Normal file
View File

@ -0,0 +1,300 @@
name: Docker cloud
on:
workflow_dispatch:
workflow_call:
permissions: {}
# Independent SHAs can build immediately on separate runners.
# Repeated requests for the same source serialize without cancelling a build.
# No mutable canary channel is promoted here; docker.yml owns that operation.
concurrency:
group: docker-cloud-${{ github.sha }}
cancel-in-progress: false
jobs:
build-and-push-cloud:
# Only canonical master builds can consume the release Fleet. The runner
# group must also allow this workflow only at refs/heads/master.
# Keep an operator switch for a full-run retry on GitHub-hosted runners.
runs-on: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }}
# Fleet instances expire after 45 minutes, including bootstrap and cleanup.
timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
# Each SHA exports its own cache. Import recent first-parent caches so
# a late older build cannot overwrite a newer build's cache manifest.
# The legacy ref keeps the first builds warm during the transition.
- name: Select cloud cache ancestry
id: cloud-cache
env:
CACHE_IMAGE: ghcr.io/${{ github.repository }}
run: |
set -euo pipefail
{
echo 'sources<<CACHE_SOURCES'
for commit in $(git rev-list --first-parent --max-count=10 HEAD); do
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud-$commit"
done
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud"
echo 'CACHE_SOURCES'
} >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
# A measured hosted cloud build started with 86 GB available.
# Keep ample headroom for BuildKit and image verification, but
# avoid minutes deleting SDKs when neither filesystem needs space.
minimum_free_kib=$((64 * 1024 * 1024))
if docker_root="$(docker info --format '{{.DockerRootDir}}')" \
&& available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \
&& [[ "$available_kib" =~ ^[0-9]+$ ]] \
&& (( available_kib >= minimum_free_kib )); then
echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup."
exit 0
fi
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (nightly-cloud, latest-cloud, <version>-cloud,
# sha-<short>-cloud). `:canary-cloud` follows the same retag-step
# ownership rule as `:canary` above.
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
id: build-cloud
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
# variant installs from server/package.json's declared version;
# add another name there when a managed tenant needs it.
build-args: |
USER_UID=1001
USER_GID=1001
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Same-SHA builds serialize above; different SHAs never share a
# writable cache ref. Registry layers are content-addressed and
# shared even when cache manifests have separate tags.
cache-from: ${{ steps.cloud-cache.outputs.sources }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
# The cloud target installs @sentry/node at the version
# server/package.json declares, into a directory the server's own
# module resolution walks. Verify the image this job just pushed, not
# a local build, so a build-cache or layer-ordering regression is
# caught before any tenant runs the image.
- name: Verify the pushed image resolves the declared Sentry version
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
test -n "$expected"
installed="$(docker run --rm --pull always \
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
--entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)"
echo "Declared optional peer version: $expected"
echo "Installed in the pushed image: $installed"
if [ "$installed" != "$expected" ]; then
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
exit 1
fi
echo "The pushed image resolves the declared @sentry/node version."
# Managed hosts run node as 1001:1001. Bake that identity into the image
# so usermod does not walk the mounted home on every container start.
# Check before the entrypoint can repair a wrongly built identity.
- name: Verify cloud runtime user
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
docker run --rm --entrypoint sh "$IMAGE" -ec '
test "$(id -u node)" = 1001
test "$(id -g node)" = 1001
test "$USER_UID" = 1001
test "$USER_GID" = 1001
'
docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec '
test "$(id -u)" = 1001
test "$(id -g)" = 1001
test -w "$PAPERCLIP_HOME"
'
# Verify the independently published cloud image without waiting for
# the self-hosted manifest job. The Sentry check already pulled it.
- name: Verify cloud PID 1 reaps orphaned processes
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh
# Cloud's commit resolver and preview-artifact planner use the full SHA.
# Publish that address only after checking this build's exact digest.
# Retagging reuses the registry manifest and does not rebuild the image.
- name: Publish verified full-SHA cloud tag
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud
run: |
set -euo pipefail
revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')"
test "$revision" = "$GITHUB_SHA"
test "$platform" = linux/amd64
docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"

View File

@ -0,0 +1,37 @@
name: Docker Runner check
on:
pull_request:
paths:
- .github/workflows/docker-runner-check.yml
- Dockerfile
- .dockerignore
- packages/paperclip-runner/rust-toolchain.toml
- packages/paperclip-runner/runner/**
- packages/paperclip-runner/protocol/**
permissions: {}
concurrency:
group: docker-runner-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
runner:
name: Compile isolated native Runner
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Compile the real target with the real .dockerignore. This catches new
# Cargo or embedded protocol inputs that the isolated COPY set omits.
# No registry credentials, cache imports/exports, or image publication.
- name: Compile the Runner from its isolated Docker context
run: docker buildx build --target runner-build --progress plain .

View File

@ -24,13 +24,20 @@ on:
type: string
default: ""
permissions:
contents: read
packages: write
# Least privilege: nothing at the workflow level; each job declares exactly
# the token scopes it uses (checkout needs contents:read, GHCR pushes need
# packages:write).
permissions: {}
# Serialise builds per ref without killing an in-flight one: a newer push
# supersedes only the pending slot, so the image build that is already
# running always finishes and publishes.
# running always finishes and publishes. Canary TAG refs each get their
# own group on purpose: their builds run in parallel so every published
# canary gets its sha images regardless of merge cadence. The mutable
# `:canary` channel tags are NOT written by the build matrix (which
# would race across parallel runs) — each canary-tag run retags the
# channel afterwards, only if it still matches the npm `canary`
# dist-tag, so the channel moves monotonically and always mirrors npm.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: false
@ -77,9 +84,51 @@ jobs:
if-no-files-found: error
retention-days: 30
# Multi-arch by native runner, not QEMU.
#
# This was one job building linux/amd64,linux/arm64 together on an x86
# runner. The arm64 half is emulated there, and it did not merely run slow:
# it wedged, every time, in `RUN pnpm --filter @paperclipai/server build`,
# emitting nothing for 38-45 minutes until `timeout-minutes: 60` killed the
# job. Verified across three consecutive runs on 2026-09-04; the amd64 half
# reached `production 5/5` minutes earlier in every one.
#
# A timed-out job is reported as *cancelled*, not failed, so the run read
# "cancelled" and the production image simply stopped publishing without
# anything going red in an obvious way.
#
# It also starved the queue. A run that burns the full hour holds the
# top-level concurrency slot for that hour, and `cancel-in-progress: false`
# keeps exactly one pending slot — so with merges arriving faster than one
# an hour, most runs were superseded before they ever started a job. Five of
# ten master commits sampled that day never produced an image at all.
#
# Each platform now builds on a runner of its own architecture and pushes by
# digest; `merge` assembles the manifest list. arm64 is kept rather than
# dropped (the cloud variant below dropped it and is amd64-only) because
# this is the self-hosted image, and ARM hosts consume it.
build-and-push:
runs-on: ubuntu-latest
strategy:
# Independent legs: one architecture failing should still publish
# nothing, but it must not also hide the other's logs behind a
# cancellation.
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
# Native builds land well inside this; it is a backstop, not a budget.
# (The interim fix while this PR landed raised the single QEMU job's cap
# to 120 minutes; native per-arch legs make that headroom unnecessary.)
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
@ -209,16 +258,18 @@ jobs:
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Lane tag mapping: master pushes publish `:canary`, nightly/v* tags
# publish `:nightly`, and only stable v* tags move `:latest` and the
# versioned tags. `:sha-<short>` is published on every build.
# Lane tag mapping: nightly/v* tags publish `:nightly`, and only
# stable v* tags move `:latest` and the versioned tags.
# `:sha-<short>` is published on every build. `:canary` is
# deliberately absent here — the channel tag is moved by the
# dist-tag-checked retag step below, never by the build matrix,
# so parallel canary builds cannot race it backwards.
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
@ -228,8 +279,8 @@ jobs:
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
@ -241,25 +292,123 @@ jobs:
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
platforms: linux/amd64,linux/arm64
push: true
platforms: ${{ matrix.platform }}
# By digest, not by tag: two runners cannot each push the same tag
# and end up with a manifest list. Each leg publishes an untagged
# image and `merge` names them together.
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
# Registry-backed BuildKit cache instead of type=gha: the Actions
# cache is capped at 10GB per repo, and two multi-arch mode=max jobs
# evict each other, so most builds ran effectively cold. The cache
# ref lives in ghcr next to the image and is written only by this
# workflow (docker.yml runs on master/tag pushes, never on PRs).
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
tags: ${{ steps.meta.outputs.tags }}
#
# Per-arch refs now the legs are separate runners: a shared ref would
# have each leg overwrite the other's cache on every build.
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.arch }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.arch }},mode=max
labels: ${{ steps.meta.outputs.labels }}
# The digest is the only thing `merge` needs from this job. Carried as an
# empty file named for it, which is the upstream pattern — the name is
# the payload, so several legs can upload without colliding on content.
- name: Export digest
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
test -n "$digest"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digests-production-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Names the per-architecture digests as one manifest list under the real
# tags. Nothing is publicly tagged until this runs, so a half-published
# multi-arch image is not a state anything can pull.
merge-and-push:
needs: build-and-push
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
# Checked out for `packages/db` (schema labels) and the orphan-reaping
# script the verification step pipes in.
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: /tmp/digests
pattern: digests-production-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Repeated from the build job rather than passed between them: job
# outputs would have to survive a matrix, and this is two `ls` calls.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Same lane mapping as the build job; this is where it is actually
# applied, since the legs push untagged. `:canary` is deliberately
# absent, exactly as in the build job's mapping: the channel tag is
# moved only by the dist-tag-checked promote_canary_channel job below,
# so parallel canary-tag builds can never race the channel backwards.
- name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
set -euo pipefail
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
# PID 1 must be an init that reaps adopted orphans. With node there, the
# orphans agent runs leave behind are never wait()ed and pin as zombies
# until the cgroup pid limit is exhausted and every fork() in the
# container fails. Run against the pushed image rather than a local
# build: the step above is multi-arch with `push: true`, so nothing is
# loaded into the runner's daemon. The cloud variant is FROM production
# and inherits the same ENTRYPOINT, so checking this image covers both.
# container fails. Run against the pushed manifest rather than a local
# build: the legs push by digest, so nothing is loaded into this
# runner's daemon. The independent cloud workflow checks its own image.
- name: Verify PID 1 reaps orphaned processes
env:
# Through the environment, not interpolated into the script body, so
@ -272,121 +421,47 @@ jobs:
echo "Verifying orphan reaping in $image"
docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh
# The cloud variant carries built bundled plugins for managed deployments
# (see the `cloud` stage in the Dockerfile). It runs as its own job with no
# `needs:` on the stock publish above, so the two builds run in parallel and
# a failure or slow build in one never gates, delays, or skips the other.
# Both jobs share only the single top-level concurrency slot. Each job is a
# separate runner, so this one carries its own copy of the prep steps
# (checkout through schema labels) — the accepted cost of that isolation.
# Master cloud builds start independently in docker-cloud.yml. Tag builds
# and manual Docker dispatches call the same implementation, preserving the
# release tags and the canary promotion dependency below.
build-and-push-cloud:
if: github.event_name != 'push' || github.ref != 'refs/heads/master'
uses: ./.github/workflows/docker-cloud.yml
permissions:
contents: read
packages: write
# Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT
# of the build jobs and serialized in its own lane, and — the load-
# bearing property — CONVERGENT rather than self-interested: a
# promotion does not promote "its own" canary, it retags the channel
# to whatever the npm `canary` dist-tag names at execution time,
# provided that version's sha images are published. GitHub's shared
# concurrency lane keeps one running and one pending promotion and
# REPLACES the pending slot with the latest enqueued — an older build
# finishing late can therefore evict the newest canary's pending
# promotion. With convergent promotion that eviction is harmless:
# whichever promotion survives resolves the current dist-tag fresh
# and lands the channel there (the current canary's images always
# exist by the time any later promotion runs, because per-tag build
# groups mean canary builds are never superseded and each run's
# promotion is gated on its own completed pushes). Every interleaving
# converges the Docker channel onto the npm channel.
promote_canary_channel:
if: startsWith(github.ref, 'refs/tags/canary/v')
# merge-and-push, not build-and-push: the per-arch legs push untagged
# digests, and the production `sha-*` tags this promotion retags only
# exist once the manifest merge has named them.
needs: [merge-and-push, build-and-push-cloud]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 10
permissions:
contents: read
packages: write
concurrency:
group: docker-canary-channel-promotion
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
env:
STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }}
EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}
run: |
set -euo pipefail
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then
[[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]]
echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict
fi
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
@ -394,99 +469,22 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud,
# <version>-cloud, sha-<short>-cloud).
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
uses: docker/build-push-action@v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
# variant installs from server/package.json's declared version;
# add another name there when a managed tenant needs it.
build-args: |
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Registry-backed BuildKit cache, separate ref from the self-hosted
# job so the two parallel builds never clobber each other's cache
# manifest (see the rationale on the job above).
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud,mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
# The cloud target installs @sentry/node at the version
# server/package.json declares, into a directory the server's own
# module resolution walks. Verify the image this job just pushed, not
# a local build, so a build-cache or layer-ordering regression is
# caught before any tenant runs the image.
- name: Verify the pushed image resolves the declared Sentry version
- name: Converge the channel tags onto the current npm canary
env:
IMAGE_TAGS: ${{ steps.meta-cloud.outputs.tags }}
IMAGE: ghcr.io/${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)"
test -n "$image"
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
test -n "$expected"
installed="$(docker run --rm --pull always \
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
--entrypoint node "$image" /app/server/.ci-sentry-probe.mjs)"
echo "Declared optional peer version: $expected"
echo "Installed in the pushed image: $installed"
if [ "$installed" != "$expected" ]; then
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
exit 1
current="$(curl -fsS "https://registry.npmjs.org/-/package/@paperclipai%2Fdb/dist-tags" | jq -er .canary)"
sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/$(printf 'canary/v%s' "$current" | jq -sRr @uri)" --jq .sha 2>/dev/null || true)"
if [ -z "$sha" ]; then
echo "canary/v${current} does not resolve yet; a later promotion converges the channel"
exit 0
fi
echo "The pushed image resolves the declared @sentry/node version."
short="$(printf '%s' "$sha" | cut -c1-7)"
if ! docker buildx imagetools inspect "$IMAGE:sha-${short}-cloud" >/dev/null 2>&1; then
echo "images for ${current} (sha-${short}) not published yet; its own promotion converges the channel"
exit 0
fi
docker buildx imagetools create -t "$IMAGE:canary" "$IMAGE:sha-${short}"
docker buildx imagetools create -t "$IMAGE:canary-cloud" "$IMAGE:sha-${short}-cloud"
echo "channel tags moved to canary ${current} (sha-${short})"

View File

@ -335,7 +335,7 @@ jobs:
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
- name: Test release verify workflow wiring
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
@ -553,7 +553,7 @@ jobs:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
if: ${{ always() }}
needs: [gate, policy, typecheck_release_registry, general_tests, build, docker_context_integrity]
needs: [gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity]
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 5
@ -564,6 +564,7 @@ jobs:
POLICY_RESULT: ${{ needs.policy.result }}
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
RUNNER_VERIFICATION_RESULT: ${{ needs.verify_paperclip_runner.result }}
BUILD_RESULT: ${{ needs.build.result }}
DOCKER_CONTEXT_INTEGRITY_RESULT: ${{ needs.docker_context_integrity.result }}
run: |
@ -572,12 +573,14 @@ jobs:
true)
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$RUNNER_VERIFICATION_RESULT" = "success"
test "$BUILD_RESULT" = "success"
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "success"
;;
false)
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"
test "$GENERAL_TESTS_RESULT" = "skipped"
test "$RUNNER_VERIFICATION_RESULT" = "skipped"
test "$BUILD_RESULT" = "skipped"
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "skipped"
;;
@ -587,8 +590,8 @@ jobs:
;;
esac
build:
name: Build
verify_paperclip_runner:
name: Verify Paperclip Runner
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
@ -633,6 +636,49 @@ jobs:
- name: Verify Paperclip Runner
run: pnpm --filter @paperclipai/paperclip-runner check:all
build:
name: Build
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
if: needs.policy.outputs.lockfile_regenerated == '1'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Runner Evalbook viewer
run: pnpm --filter @paperclipai/paperclip-runner build:issue-thread

View File

@ -58,16 +58,59 @@ jobs:
fail-fast: false
matrix:
include:
- group: general-server
group_label: server (1/3)
# Split the long chat file by collected test locations, and balance
# the remaining server files across ten runners. Normal PR/local
# invocations retain their complete general-server group.
- group: general-server-without-chat
group_label: server (1/10)
shard_index: 0
shard_count: 10
- group: general-server-without-chat
group_label: server (2/10)
shard_index: 1
shard_count: 10
- group: general-server-without-chat
group_label: server (3/10)
shard_index: 2
shard_count: 10
- group: general-server-without-chat
group_label: server (4/10)
shard_index: 3
shard_count: 10
- group: general-server-without-chat
group_label: server (5/10)
shard_index: 4
shard_count: 10
- group: general-server-without-chat
group_label: server (6/10)
shard_index: 5
shard_count: 10
- group: general-server-without-chat
group_label: server (7/10)
shard_index: 6
shard_count: 10
- group: general-server-without-chat
group_label: server (8/10)
shard_index: 7
shard_count: 10
- group: general-server-without-chat
group_label: server (9/10)
shard_index: 8
shard_count: 10
- group: general-server-without-chat
group_label: server (10/10)
shard_index: 9
shard_count: 10
- group: general-chat
group_label: chat (1/3)
shard_index: 0
shard_count: 3
- group: general-server
group_label: server (2/3)
- group: general-chat
group_label: chat (2/3)
shard_index: 1
shard_count: 3
- group: general-server
group_label: server (3/3)
- group: general-chat
group_label: chat (3/3)
shard_index: 2
shard_count: 3
# Keep parity with pr.yml: workspaces-a is split with Vitest's
@ -191,8 +234,8 @@ jobs:
- name: Run deterministic Runner workflow scorer tests
run: pnpm test:runner-workflow-evals
build:
name: Build
verify_paperclip_runner:
name: Verify Paperclip Runner
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@ -215,11 +258,62 @@ jobs:
node-version: 24
cache: pnpm
- name: Select the pinned Runner Rust toolchain
working-directory: packages/paperclip-runner
run: |
set -euo pipefail
rustup show
toolchain="$(rustup show active-toolchain | awk '{print $1}')"
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
- name: Cache Runner Rust dependencies
# Restore and save only within trusted master-push verification. GitHub
# isolates branch/PR caches from master; other callers compile afresh.
if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: packages/paperclip-runner/runner -> target
shared-key: release-runner-v1
# Rebuild workspace code and rerun every check. Cache only compiled
# dependencies; never restore installed executables from cargo/bin.
cache-workspace-crates: false
cache-bin: false
# The step guard also restricts restores. Save only after a successful
# master-push verification of that push's exact commit.
save-if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Verify Paperclip Runner
run: pnpm --filter @paperclipai/paperclip-runner check:all
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build
run: pnpm build

View File

@ -1,4 +1,5 @@
name: Release
run-name: ${{ inputs.channel == 'preview' && format('Stack deploy {0} build', inputs.request_id) || inputs.channel == 'cloud-migrator' && format('Cloud migrator {0}', inputs.source_ref) || 'Release' }}
on:
push:
@ -17,12 +18,22 @@ on:
- stable
- beta
- nightly
- preview
- cloud-migrator
default: stable
source_ref:
description: (stable) Commit SHA, branch, or tag to publish as stable
description: Stable source ref, or full immutable SHA for a preview or cloud migrator build
required: true
type: string
default: master
request_id:
description: (preview/cloud-migrator) Correlation UUID
type: string
default: ""
preview_migrator:
description: (preview) Publish isolated shared and database packages if missing
type: boolean
default: false
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
required: false
@ -46,7 +57,7 @@ on:
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
group: ${{ (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && format('{0}-{1}', inputs.channel, inputs.source_ref) || format('release-{0}-{1}', github.event_name, github.ref) }}
cancel-in-progress: false
env:
@ -64,11 +75,247 @@ env:
NPM_PUBLISH_VERIFY_DELAY_SECONDS: "10"
jobs:
plan_preview:
name: Check preview artifacts
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
image: ${{ steps.plan.outputs.image }}
packages: ${{ steps.plan.outputs.packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Validate immutable source and inspect existing artifacts
id: plan
env:
SOURCE_SHA: ${{ inputs.source_ref }}
REQUEST_ID: ${{ inputs.request_id }}
PREVIEW_MIGRATOR: ${{ inputs.preview_migrator }}
PLAN_COMMAND: ${{ inputs.channel == 'cloud-migrator' && 'plan-migrator' || 'plan' }}
run: node scripts/preview-artifacts.mjs "$PLAN_COMMAND" "$SOURCE_SHA" "$REQUEST_ID" "$PREVIEW_MIGRATOR"
package_preview:
name: Build preview migrator
needs: plan_preview
if: needs.plan_preview.outputs.packages == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: trusted
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.source_ref }}
path: source
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Install build dependencies without lifecycle scripts
working-directory: source
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Build and pack exact-source preview packages
env:
SOURCE_SHA: ${{ inputs.source_ref }}
run: node trusted/scripts/preview-artifacts.mjs pack source packages "$SOURCE_SHA"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: preview-packages
overwrite: true
path: packages/*.tgz
if-no-files-found: error
retention-days: 7
publish_preview:
name: Publish preview migrator
needs: [plan_preview, package_preview]
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.packages == 'true' && needs.package_preview.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
# A manual preview and a merge-triggered migrator may compile in parallel.
# Serialize only publication so they cannot race an immutable npm version,
# without making the migrator wait for a preview's separate image build.
concurrency:
group: preview-package-publish-${{ inputs.source_ref }}
cancel-in-progress: false
# Reuse release.yml's established npm trusted-publisher identity. This job
# publishes only isolated preview versions; it cannot advance lane tags.
environment: npm-canary
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Install npm with trusted publishing support
run: npm install --global npm@11.18.0 --ignore-scripts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: preview-packages
path: preview-packages
- name: Publish immutable preview packages without running package code
env:
SOURCE_SHA: ${{ inputs.source_ref }}
run: node scripts/preview-artifacts.mjs publish preview-packages "$SOURCE_SHA"
image_preview:
name: Build preview cloud image
needs: plan_preview
if: needs.plan_preview.outputs.image == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.source_ref }}
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Prepare locked image context
env:
SOURCE_SHA: ${{ inputs.source_ref }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
pnpm install --resolution-only --ignore-scripts --ignore-pnpmfile --no-frozen-lockfile
echo "TOOLS_EPOCH=$(date -u +%G-W%V)" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build the immutable cloud image without registry credentials
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: cloud
platforms: linux/amd64
push: false
provenance: false # Docker archives cannot carry registry attestations.
outputs: type=docker,dest=${{ runner.temp }}/preview-image.tar
tags: ghcr.io/paperclipai/paperclip:sha-${{ inputs.source_ref }}-cloud
build-args: |
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_COMMIT=${{ inputs.source_ref }}
PAPERCLIP_BUILD_VERSION=0.0.0-preview.g${{ inputs.source_ref }}
CLI_TOOLS_CACHE_EPOCH=${{ env.TOOLS_EPOCH }}
labels: |
org.opencontainers.image.revision=${{ inputs.source_ref }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: preview-image
overwrite: true
path: ${{ runner.temp }}/preview-image.tar
compression-level: 0
if-no-files-found: error
retention-days: 1
publish_image_preview:
name: Publish preview cloud image
needs: [plan_preview, image_preview]
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.image == 'true' && needs.image_preview.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
# This existing environment has an external master-only branch policy.
environment: npm-canary
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: preview-image
path: preview-image
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify image identity and publish without executing image code
env:
SOURCE_SHA: ${{ inputs.source_ref }}
run: node scripts/preview-artifacts.mjs publish-image preview-image/preview-image.tar "$SOURCE_SHA"
result_preview:
name: Verify preview artifacts
needs: [plan_preview, image_preview, publish_image_preview, package_preview, publish_preview]
if: >-
always() && inputs.channel == 'preview' && needs.plan_preview.result == 'success' &&
(needs.publish_image_preview.result == 'success' || needs.plan_preview.outputs.image == 'false') &&
(needs.publish_preview.result == 'success' || needs.plan_preview.outputs.packages == 'false')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Confirm exact artifacts are visible
env:
SOURCE_SHA: ${{ inputs.source_ref }}
REQUEST_ID: ${{ inputs.request_id }}
PREVIEW_MIGRATOR: ${{ inputs.preview_migrator }}
run: node scripts/preview-artifacts.mjs result "$SOURCE_SHA" "$REQUEST_ID"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: stack-deploy-result
overwrite: true
path: stack-deploy-result/result.json
if-no-files-found: error
retention-days: 30
verify_canary:
if: github.event_name == 'push'
uses: ./.github/workflows/release-verify.yml
with:
ref: ${{ github.sha }}
name: Reuse exact-source verification
if: github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 50
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Require successful source checks for this exact master push
env:
GITHUB_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ github.sha }}
run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA"
publish_canary:
if: github.event_name == 'push'
@ -81,6 +328,8 @@ jobs:
permissions:
contents: write
id-token: write
# For the explicit docker.yml dispatch below.
actions: write
steps:
- name: Checkout repository
@ -139,6 +388,29 @@ jobs:
git push origin "refs/tags/${tag}"
echo "version=${tag#canary/v}" >> "$GITHUB_OUTPUT"
# Canary images previously relied on the master-push docker.yml run,
# whose single pending concurrency slot gets superseded by every
# newer push — on a busy day no canary image publishes at all (five
# consecutive canaries shipped npm packages with no cloud image on
# 2026-09-06, starving downstream managed deploys for ~18 hours).
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's
# triggers, so dispatch the image build at the canary tag
# explicitly, exactly like the nightly and beta lanes: the run keys
# its concurrency off the tag ref, so no master push can supersede
# it, and docker.yml's `type=sha` mapping publishes the
# sha-<short> and sha-<short>-cloud images either way.
- name: Build Docker images for the canary tag
env:
GH_TOKEN: ${{ github.token }}
run: |
{
echo "## Canary published"
echo ""
echo "- Published canary: \`${{ steps.canary_tag.outputs.version }}\`"
echo "- Docker build dispatched at \`canary/v${{ steps.canary_tag.outputs.version }}\`"
} >> "$GITHUB_STEP_SUMMARY"
gh workflow run docker.yml --ref "refs/tags/canary/v${{ steps.canary_tag.outputs.version }}" --repo "$GITHUB_REPOSITORY"
# The package is already public when this gate runs. A red result leaves the
# immutable canary in npm, but makes the release workflow visibly fail before
# anyone mistakes an installable package for an onboardable one.

View File

@ -12,7 +12,9 @@ on:
type: string
concurrency:
group: runner-chaos-evals-${{ inputs.ref || github.ref }}
# Reusable calls inherit the caller's workflow name. Cloud readiness and
# Release verify the same SHA independently and must not cancel each other.
group: runner-chaos-evals-${{ github.workflow }}-${{ inputs.ref || github.ref }}
cancel-in-progress: true
jobs:

169
.github/workflows/storybook-deploy.yml vendored Normal file
View File

@ -0,0 +1,169 @@
name: Storybook Deploy
on:
workflow_dispatch:
inputs:
branch:
description: "Repository branch to publish (empty uses the selected workflow branch)"
type: string
default: ""
# Also exposed by Storybook Visual, which is already available on master.
workflow_call:
inputs:
branch:
type: string
default: ""
permissions:
contents: read
jobs:
authorize:
name: Authorize Storybook publisher
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.source.outputs.sha }}
branch: ${{ steps.source.outputs.branch }}
branch_key: ${{ steps.source.outputs.branch_key }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Require CODEOWNER initiator and rerunner
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
await authorize({ github, context });
- name: Pin requested repository branch
id: source
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
SOURCE_BRANCH: ${{ inputs.branch }}
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
with:
script: |
const branch = process.env.SOURCE_BRANCH || context.ref.slice('refs/heads/'.length);
const { data } = await github.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` });
if (data.ref !== `refs/heads/${branch}` || data.object.type !== 'commit') {
throw new Error('Select an existing branch in this repository.');
}
// With no source override, preserve the exact dispatched commit.
const sha = process.env.SOURCE_BRANCH ? data.object.sha : context.sha;
const { storybookDestination } = require('./.github/scripts/storybook-destination.cjs');
const destination = storybookDestination({ branch, sha,
runId: context.runId, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL });
core.setOutput('sha', sha);
core.setOutput('branch_key', destination.branchKey);
core.setOutput('branch', branch);
build:
name: Build selected branch Storybook
permissions: {}
needs: authorize
runs-on: ubuntu-latest
timeout-minutes: 25
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
env:
STORYBOOK_DISABLE_TELEMETRY: "1"
steps:
- name: Download public source without repository credentials
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
curl --fail --silent --show-error --location --retry 3 \
"https://codeload.github.com/paperclipai/paperclip/tar.gz/$SOURCE_SHA" \
--output "$RUNNER_TEMP/source.tar.gz"
tar -xzf "$RUNNER_TEMP/source.tar.gz" --strip-components=1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
package-manager-cache: false
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm build-storybook
- name: Record source and validate output
id: artifact
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
run: |
test -s ui/storybook-static/index.html
test -s ui/storybook-static/iframe.html
test -s ui/storybook-static/index.json
jq -n --arg sha "$SOURCE_SHA" --arg branch "$SOURCE_BRANCH" \
'{sha: $sha, branch: $branch}' > ui/storybook-static/deployment.json
echo "name=storybook-deploy-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.artifact.outputs.name }}
path: ui/storybook-static
if-no-files-found: error
retention-days: 7
deploy:
name: Publish branch Storybook to S3
needs: [authorize, build]
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: storybook-deploy-${{ needs.authorize.outputs.branch_key }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
environment:
name: storybook-deploy
url: ${{ steps.deployment.outputs.url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Recheck CODEOWNER access before publishing
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
await authorize({ github, context });
- name: Download the successful build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.build.outputs.artifact_name }}
path: storybook-static
- name: Assume the Storybook-only uploader role
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with:
role-to-assume: ${{ vars.STORYBOOK_AWS_ROLE_ARN }}
aws-region: ${{ vars.STORYBOOK_AWS_REGION }}
role-duration-seconds: 900
- name: Publish this branch preview
id: deployment
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
run: node .github/scripts/publish-storybook.cjs
- name: Verify public build and stable branch URL
env:
BUILD_URL: ${{ steps.deployment.outputs.build_url }}
BRANCH_URL: ${{ steps.deployment.outputs.url }}
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
run: node .github/scripts/verify-storybook.cjs
- name: Upload deployment links
if: ${{ !cancelled() && steps.deployment.outcome == 'success' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: storybook-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.deployment.outputs.report_path }}
if-no-files-found: error
retention-days: 30

View File

@ -3,6 +3,16 @@ name: Storybook Visual
on:
workflow_dispatch:
inputs:
branch:
description: "Repository branch to publish (deployment only; empty uses the selected workflow branch)"
required: false
type: string
default: ""
deploy_preview:
description: "Publish this branch to S3/CloudFront instead of running visual tests (CODEOWNERS only)"
required: false
type: boolean
default: false
update_snapshots:
description: "Generate updated snapshots and a baseline review bundle"
required: false
@ -18,7 +28,7 @@ on:
- labeled
concurrency:
group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
group: storybook-visual-${{ inputs.deploy_preview && github.run_id || 'visual' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
@ -28,7 +38,7 @@ jobs:
visual:
name: Storybook visual regression
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' && !inputs.deploy_preview) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
runs-on: ubuntu-latest
timeout-minutes: 35
@ -100,3 +110,13 @@ jobs:
path: tests/storybook-visual/baseline-review/snapshots.tgz
retention-days: 30
if-no-files-found: error
preview:
name: Deploy selected branch Storybook
if: github.event_name == 'workflow_dispatch' && inputs.deploy_preview
permissions:
contents: read
id-token: write
uses: ./.github/workflows/storybook-deploy.yml
with:
branch: ${{ inputs.branch }}

View File

@ -35,6 +35,16 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o
7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run.
8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files."
## Contextual feedback
Do not show a toast for task or run state already visible on the current screen.
This includes descendant runs represented by the open subtree. Show local action
results in place; keep failures actionable inline. Notifications for other work
remain useful. Expected cancellation is neutral gray, not an error. A paused task replaces the composer with an amber takeover. It says “Task is
paused.” and “Resume this task to send a message.” with a “Resume task” action.
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
dismissed, retains drafts, and hides message inputs until the pause is released.
## Enforcement (what "compliant" means for the extraction run)
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.

View File

@ -51,7 +51,7 @@ COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
FROM base AS build
FROM base AS rust-toolchain
WORKDIR /app
# Debian's packaged rust lags the ecosystem (trixie ships 1.85) and the
# runner's dependency tree now requires a newer rustc. Install rustup from a
@ -83,8 +83,31 @@ RUN set -eux; \
chmod +x /tmp/rustup-init; \
/tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain none; \
rm /tmp/rustup-init
# Install the package-owned compiler before any application source enters the
# stage. rustup-init above installs rustup itself, not the selected compiler.
COPY packages/paperclip-runner/rust-toolchain.toml /tmp/runner-toolchain/rust-toolchain.toml
RUN cd /tmp/runner-toolchain && rustup show
FROM rust-toolchain AS runner-build
WORKDIR /app/packages/paperclip-runner
# Rust embeds protocol schemas and fixtures with include_str!. Keep those
# alongside the complete Cargo workspace so every compile-time input keys
# this layer. Ordinary server/UI edits can then reuse the native build.
COPY packages/paperclip-runner/rust-toolchain.toml ./
COPY packages/paperclip-runner/runner ./runner
COPY packages/paperclip-runner/protocol ./protocol
# Cargo fingerprints source mtimes. Normalize them here and after the full
# source copy below so a fresh checkout cannot invalidate unchanged inputs.
RUN find runner protocol -type f -exec touch -d @0 {} + \
&& touch -d @0 rust-toolchain.toml \
&& cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd
FROM runner-build AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN find packages/paperclip-runner/runner packages/paperclip-runner/protocol -type f -exec touch -d @0 {} + \
&& touch -d @0 packages/paperclip-runner/rust-toolchain.toml
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
# The server build runs scripts/write-build-stamp.mjs, which stamps the built
@ -103,14 +126,6 @@ RUN rm -rf packages/paperclip-runner/runner/target
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
# Real version for this build, computed from `git describe` on the CI runner
# (the image has no .git, so the server cannot derive it at runtime). Empty for
# local `docker build`, which just leaves the server on its normal fallbacks.
ARG PAPERCLIP_BUILD_VERSION=""
# The exact commit this image was built from, for the same reason: server-info
# falls back to PAPERCLIP_BUILD_COMMIT when git is unavailable, which feeds the
# /api/health `commit` field that deploy tooling verifies. Empty locally.
ARG PAPERCLIP_BUILD_COMMIT=""
# Refreshes the tool layer below when it changes (CI stamps an ISO week, so
# the @latest CLI tools advance weekly). Without it the cached layer would
# freeze the tools until an unrelated cache bust.
@ -133,6 +148,13 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
# Declare per-build metadata after the stable RUN layers. Docker includes
# in-scope ARG values in a RUN's environment even when its command does not
# mention them; declaring these earlier invalidates the weekly tool cache.
# The build stage still receives the commit before writing dist/build-info.json.
# Empty for local builds, preserving the server's normal version fallbacks.
ARG PAPERCLIP_BUILD_VERSION=""
ARG PAPERCLIP_BUILD_COMMIT=""
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \

View File

@ -1,4 +1,5 @@
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@ -121,6 +122,29 @@ describe("managed install store", () => {
expect(fs.statSync(rcPath).mode & 0o777).toBe(0o640);
});
it("uses the pinned Node for child tools even with an older node first on the service PATH", () => {
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, `console.log(require("node:child_process").execFileSync("node", ["-p", "process.execPath"], {encoding: "utf8"}).trim())`);
const oldBin = path.join(root, "old-bin");
fs.mkdirSync(oldBin);
fs.writeFileSync(path.join(oldBin, "node"), "#!/bin/sh\nexit 42\n", { mode: 0o755 });
writeManagedShim(paths);
const output = execFileSync(paths.shimPath, [], { env: { ...process.env, PATH: oldBin }, encoding: "utf8" });
expect(fs.realpathSync(output.trim())).toBe(fs.realpathSync(process.execPath));
expect(removeManagedShim(paths)).toBe(true);
});
it("upgrades and removes the original managed shim format", () => {
writeManagedShim(paths);
const original = fs.readFileSync(paths.shimPath, "utf8").split("\n").filter((line) => !line.startsWith("export PATH=")).join("\n");
fs.writeFileSync(paths.shimPath, original);
writeManagedShim(paths);
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain("export PATH=");
fs.writeFileSync(paths.shimPath, original);
expect(removeManagedShim(paths)).toBe(true);
});
it("rejects marker substrings that are not the exact managed shim format", () => {
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`);

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

@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import { writeManagedShim, flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import type { CommandRunner } from "../commands/install.js";
import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
@ -36,6 +36,56 @@ afterEach(() => {
});
describe("update command", () => {
it.each(["npm", "git", "global-npm"] as const)("rejects %s updates on unsupported Node before any update work", async (source) => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "1.0.0");
const entrypoint = createPayload(payload, "1.0.0");
flipCurrentAtomic(payload, paths);
const manifest: InstallManifest = { schemaVersion: 1, ...record(payload, "1.0.0"), source: source === "git" ? "git" : "npm", previous: [] };
writeInstallManifestAtomic(manifest, paths);
const runCommand = vi.fn<CommandRunner>();
const backup = vi.fn();
const restartActiveService = vi.fn();
const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!;
Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" });
try {
await expect(updateCommand({ yes: true }, {
paths,
executablePath: source === "global-npm" ? path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js") : entrypoint,
runCommand, backup, restartActiveService,
})).rejects.toThrow("npx paperclipai@latest install --yes");
expect(runCommand).not.toHaveBeenCalled();
expect(backup).not.toHaveBeenCalled();
expect(restartActiveService).not.toHaveBeenCalled();
expect(readInstallManifest(paths)).toEqual(manifest);
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payload));
} finally {
Object.defineProperty(process.versions, "node", nodeVersion);
}
});
it("keeps update checks, dry runs, and rollback available on unsupported Node", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); createPayload(oldPayload, "1.0.0");
const payload = payloadPathFor(paths, "npm", "2.0.0"); const executablePath = createPayload(payload, "2.0.0");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [record(oldPayload, "1.0.0")] }, paths);
const runCommand = vi.fn(async () => ({ stdout: '\"3.0.0\"\n', stderr: "" }));
const restartActiveService = vi.fn(async () => false);
const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!;
Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" });
try {
await updateCommand({ check: true }, { paths, executablePath, runCommand });
await updateCommand({ dryRun: true }, { paths, executablePath, runCommand });
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
await updateCommand({ rollback: true }, { paths, executablePath, restartActiveService });
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
expect(restartActiveService).toHaveBeenCalledWith("1.0.0");
} finally {
Object.defineProperty(process.versions, "node", nodeVersion);
}
});
it("orders SemVer prerelease identifiers numerically", () => {
expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0);
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
@ -72,12 +122,16 @@ describe("update command", () => {
fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
writeManagedShim(paths);
// Simulate a launcher generated before child-runtime PATH pinning existed.
fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, ""));
const backup = vi.fn(async () => undefined);
const confirm = vi.fn(async () => true);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`));
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`);
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
expect(readInstallManifest(paths)?.sha).toBe(newSha);
@ -134,6 +188,9 @@ describe("update command", () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
writeManagedShim(paths);
// Simulate a launcher generated before child-runtime PATH pinning existed.
fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, ""));
const backup = vi.fn(async () => undefined);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string, args: string[]) => {
@ -142,6 +199,7 @@ describe("update command", () => {
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`);
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
expect(readInstallManifest(paths)?.version).toBe("2.0.0");

View File

@ -5,6 +5,8 @@ import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import {
agents,
@ -13,6 +15,8 @@ import {
companies,
companyMemberships,
createDb,
closeRegisteredClients,
ensurePostgresDatabase,
executionWorkspaces,
inspectMigrations,
issueComments,
@ -1644,17 +1648,32 @@ describe("worktree helpers", () => {
const sourceEnvPath = path.join(sourceConfigDir, ".env");
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
onTestFinished(() => sourceDb.cleanup());
await seedValidWorktreeSource(sourceDb.connectionString);
const sourceCluster = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
const sourceUrl = new URL(sourceCluster.connectionString);
sourceUrl.pathname = "/lagging_source";
const sourceDb = { connectionString: sourceUrl.toString() };
onTestFinished(async () => {
await closeRegisteredClients(sourceDb.connectionString);
await sourceCluster.cleanup();
});
await ensurePostgresDatabase(sourceCluster.connectionString, "lagging_source");
// A lagging source must also have the prior schema. Deleting only the
// newest receipt from a fully migrated schema relied on that particular
// migration being idempotent and breaks when the new migration creates a
// table. Build the actual all-but-last schema before shuffling its history.
const migrationsRoot = new URL("../../../packages/db/src/migrations/", import.meta.url);
const journal = JSON.parse(fs.readFileSync(new URL("meta/_journal.json", migrationsRoot), "utf8"));
const priorEntries = journal.entries.slice(0, -1);
const priorMigrations = path.join(tempRoot, "prior-migrations");
fs.mkdirSync(path.join(priorMigrations, "meta"), { recursive: true });
fs.writeFileSync(path.join(priorMigrations, "meta", "_journal.json"), JSON.stringify({ ...journal, entries: priorEntries }));
for (const entry of priorEntries) {
fs.copyFileSync(new URL(`${entry.tag}.sql`, migrationsRoot), path.join(priorMigrations, `${entry.tag}.sql`));
}
const sourceDbClient = createDb(sourceDb.connectionString);
await migrate(drizzle(sourceDbClient.$client), { migrationsFolder: priorMigrations });
await seedValidWorktreeSource(sourceDb.connectionString);
await sourceDbClient.$client.unsafe(`
DELETE FROM "drizzle"."__drizzle_migrations"
WHERE "id" = (
SELECT max("id") FROM "drizzle"."__drizzle_migrations"
);
WITH pair AS (
SELECT
array_agg("id" ORDER BY "id" DESC) AS ids,

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

@ -84,9 +84,9 @@ export function resolveGitInstallWorkspacePackages(checkoutPath: string): Releas
return ordered;
}
function assertSupportedNodeVersion(): void {
export function assertSupportedNodeVersion(): void {
if (!isSupportedNodeVersion(process.versions.node)) {
throw new Error(`Managed installs require Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version}).`);
throw new Error(`Installing or updating Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version} at ${process.execPath}). Put a supported Node bin directory first on PATH and run 'npx paperclipai@latest install --yes' to re-pin an existing managed install.`);
}
}

View File

@ -5,9 +5,9 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
import { assertManagedShimWritable, writeManagedShim, buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
import { dbBackupCommand } from "./db-backup.js";
import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
import { assertSupportedNodeVersion, installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
import { resolveConfigPath } from "../config/store.js";
import { detectServiceManager } from "../services/service-manager.js";
@ -180,6 +180,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
}
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
if (!options.check && !options.dryRun) assertSupportedNodeVersion();
const request = resolveUpdateRequest(mode === "managed" ? manifest : null, options);
if (mode === "managed" && manifest?.source === "git") {
if (!manifest.repo || !manifest.ref || !manifest.sha) throw new Error("Managed git install metadata is incomplete.");
@ -193,7 +194,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
}
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
const installed = await withInstallStoreLock(async () => {
assertManagedShimWritable(paths);
const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths);
writeManagedShim(paths);
const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
@ -247,7 +250,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion}${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
const installed = await withInstallStoreLock(async () => {
assertManagedShimWritable(paths);
const payload = await installNpmPayload(targetVersion, runCommand, paths);
writeManagedShim(paths);
const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }

View File

@ -380,13 +380,17 @@ function shellQuote(value: string): string {
function isManagedShimContents(contents: string): boolean {
const lines = contents.split("\n");
// Accept the original pinned-runtime shim so upgrades can replace it.
const withRuntimePath = lines.length === 6;
const execIndex = withRuntimePath ? 4 : 3;
return (
lines.length === 5 &&
(lines.length === 5 || withRuntimePath) &&
lines[0] === "#!/bin/sh" &&
lines[1] === `# ${MANAGED_SHIM_MARKER}` &&
lines[2] === "set -eu" &&
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) &&
lines[4] === ""
(!withRuntimePath || /^export PATH='(?:[^']|'"'"')+':"\$\{PATH:-\/usr\/local\/bin:\/usr\/bin:\/bin\}"$/.test(lines[3])) &&
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[execIndex]) &&
lines[execIndex + 1] === ""
);
}
@ -399,7 +403,9 @@ export function writeManagedShim(paths = resolveInstallStorePaths()): void {
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 });
assertManagedShimWritable(paths);
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
// ACP servers and package-manager shims use /usr/bin/env node. Pin their
// runtime too, even when systemd/launchd supplies a different PATH.
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexport PATH=${shellQuote(path.dirname(process.execPath))}:"\${PATH:-/usr/local/bin:/usr/bin:/bin}"\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
writeFileAtomic(paths.shimPath, contents, 0o755);
}

View File

@ -29,6 +29,8 @@ describe("isSupportedNodeVersion", () => {
expect(warning).toContain(NODE_VERSION_INSTALL_GUIDE_URL);
expect(warning).toContain("piped install.sh form cannot upgrade");
expect(warning).toContain("Restart Paperclip after upgrading");
expect(warning).toContain(process.execPath);
expect(warning).toContain("startup executable and PATH");
});
it("emits at most one warning when CLI and server boot in the same process", () => {

View File

@ -4,5 +4,5 @@
"outDir": "dist",
"rootDir": ".."
},
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
"include": ["src", "../packages/shared/src", "../server/src/types/express.d.ts", "../packages/plugins/create-paperclip-plugin/src"]
}

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

@ -122,8 +122,10 @@ All of these are optional; when unset, the driver defaults apply and behavior is
```sh
DATABASE_PREPARED_STATEMENTS=false # required for transaction-mode poolers; default: enabled
DATABASE_POOL_MAX=25 # connection pool size; default: 10
DATABASE_IDLE_TIMEOUT_SECONDS=60 # close idle pooled connections; default: keep open
DATABASE_IDLE_TIMEOUT_SECONDS=60 # close idle pooled connections; default: 60 (0 = keep open)
DATABASE_CONNECT_TIMEOUT_SECONDS=10 # default: 30
DATABASE_MAX_LIFETIME_SECONDS=1800 # recycle a pooled connection after this long; default: 30-60 min (random)
DATABASE_APPLICATION_NAME=paperclip # application_name in pg_stat_activity; default: paperclip
```
### Push the schema
@ -245,6 +247,15 @@ finalization ledger, whose retry time and owner lease are checked under a row
lock. None of these writes selects a runtime or changes a legacy run's execution
path.
Durable agent session goals are an additive projection on
`agent_task_sessions`, distinct from the business-goal hierarchy. The row stores
the negotiated goal capability, normalized snapshot and status, desired state,
provider source cursor, monotonic projection revision, and observation time.
`agent_session_goal_actions` is the control outbox: `(session_id, request_id)`
is unique, so retries return the original accepted action. Provider source
ordering fences duplicate and stale updates, and a cleared projection retains
its revision/cursor tombstone so an older provider event cannot resurrect it.
Issue `status_version` advances only when `status` changes. The JavaScript backup
path includes user-defined functions and triggers so a restored database keeps
that invariant. Removing or disabling a future native rollout flag must not
@ -260,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

@ -82,6 +82,12 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance**
to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the
next question. Reduced-motion mode advances without animation.
Multi-select and custom answers wait for Next, and the final page waits for
Submit answers. The adjacent **Verified** story exercises the full flow.
The Storybook visual regression suite uses external PNG baselines instead of
committed screenshots:
@ -110,6 +116,70 @@ workflow manually, to produce downloadable Playwright report/test-result
artifacts. Normal PR visual runs use read-only repository permissions and do not
upload or mutate baseline objects.
### Publish a branch Storybook
CODEOWNERS can publish a repository branch through **Actions → Storybook Deploy →
Run workflow**. Keep the workflow branch on `master` and enter the source branch
in `branch`. The source branch does not need to contain the workflow. Leaving
`branch` empty publishes the selected workflow branch's dispatched commit.
```sh
gh workflow run storybook-deploy.yml --ref master -f branch=your-branch
```
The existing **Storybook Visual** workflow also offers a `deploy_preview` checkbox,
which publishes through the same workflow instead of running visual tests:
```sh
gh workflow run storybook-visual.yml --ref master -f deploy_preview=true -f branch=your-branch
```
Approve the `storybook-deploy` environment as a CODEOWNER. The workflow summary
links the **stable branch URL** and **this build**. The run also uploads a
`storybook-deployment-<run-id>-<attempt>` artifact containing
`storybook-deployment.md` with both links and the source commit. Different branches have
different URLs; publishing one never replaces another. Redeploying the same
branch updates its stable URL only after all files for the new build are uploaded.
Previous build links keep working. The branch entry preserves Storybook query
parameters and fragments when redirecting to the completed build.
Bookmark URLs use `storybook/branches/<branch>/`, for example
`https://d1p6rlowie26tp.cloudfront.net/storybook/branches/master/`.
Copy the **stable branch URL** from the run summary when saving a bookmark;
opening it redirects to the latest published build. Branch names preserve case.
Characters other than letters, digits, `_`, and `-` use `~HH` UTF-8 escapes, so
`feature/foo` becomes `feature~2Ffoo` and stays distinct from `feature-foo`.
Names ending in a hyphen and 16 lowercase hex digits escape that hyphen to
reserve the existing build directories. Very long names use a hash suffix.
Existing hashed branch URLs keep updating and remain valid. Build files remain
under `storybook/branches/<readable-branch>-<hash>/builds/<run-id>-<attempt>/`.
`deployment.json` in each build records its branch, source commit and URLs.
Builds run independently; publication is serialized per branch. Retained builds
are not automatically deleted and will accumulate until an operator prunes them.
Publishing requires both the original actor and the current rerunner to be
individual GitHub accounts named in `.github/CODEOWNERS` on the current default
branch. Comments, teams and email entries do not grant access. Authorization runs
before the build and again before deployment, including deployment-only reruns.
GitHub also requires a CODEOWNER environment approval, so editing authorization
code on a branch cannot grant AWS access without an authorized reviewer.
The build downloads the public source archive with no GitHub token permissions,
AWS credentials or repository secrets. Dependency caching and install lifecycle
scripts are disabled. The separate publisher uses GitHub OIDC to assume a role limited to
`storybook/branches/*`. It treats the build artifact as static files and runs only
the publisher from the workflow checkout. It cannot delete objects, change AWS
settings, or overwrite the runner dashboard. The Storybook site itself is public.
Pushes and PR events never publish it.
The existing S3 bucket and CloudFront distribution also serve runner reports in
separate prefixes. GitHub Pages and its dashboard workflow are independent.
See [Storybook deployment setup](STORYBOOK-DEPLOYMENT.md) for the environment,
repository variables, AWS policies and one-time operator setup.
GitHub requires a new dispatch workflow to exist on the default branch before
it becomes a manual entry point.
## UI Fonts And Screenshots
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
@ -131,6 +201,13 @@ pnpm dev:stop
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
Worktree dependency provisioning records its fingerprint only after a successful
install. Frozen installs with outdated lockfiles or patched-dependency hash
mismatches retry once without `--frozen-lockfile`; other failures retain their
exit status. Patch contents are part of the install fingerprint. Generated
lockfile changes remain local to the worktree; the repository's lockfile bot
owns committed updates.
## Hot-Restart Deploys
Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID:
@ -153,7 +230,7 @@ at least one identity source. Supported-platform process probes fail explicitly
instead of silently treating a live PID as either the original owner or a
recycled process when identity cannot be established.
Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, stops new scheduler work, waits for any queue-claim callback already in flight, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. ACP-backed local runs use server-owned stdio and cannot survive their parent server, so the old server instead persists their complete snapshot, changes the marker to `drainRequired` with `drainReason: "active_acp_run"`, and drains only those runs to queued retries. Detached CLI runs remain eligible for adoption during the same mixed restart. If an ACP process terminates but its terminal run update does not persist, startup classifies it as lost with reason `selective_drain_not_finalized` rather than treating the drain as successful. On startup the new server writes `$PAPERCLIP_HOME/instances/${PAPERCLIP_INSTANCE_ID:-default}/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `drainReason`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs.
Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, stops new scheduler work, waits for any queue-claim callback already in flight, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. ACP-backed local runs use server-owned stdio and cannot survive their parent server, so the old server instead persists their complete snapshot, changes the marker to `drainRequired` with `drainReason: "active_acp_run"`, and drains only those runs to bounded conversation retries. These retries resume the prior session when compatible, otherwise carry the full task conversation into a fresh session. They do not automatically replay tool calls or require receipts for every prior action. Detached CLI runs remain eligible for adoption during the same mixed restart. If an ACP process terminates but its terminal run update does not persist, startup classifies it as lost with reason `selective_drain_not_finalized` rather than treating the drain as successful. On startup the new server writes `$PAPERCLIP_HOME/instances/${PAPERCLIP_INSTANCE_ID:-default}/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `drainReason`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs.
When Paperclip manages embedded PostgreSQL, it suppresses that dependency's eager
`SIGINT`/`SIGTERM` cleanup hooks. Paperclip owns signal ordering so the heartbeat
@ -800,12 +877,32 @@ When a workspace service runs Paperclip for browser OAuth QA, configure its `exp
## Paperclip Runner Adapter Conversion
The experimental Paperclip Runner currently qualifies four local profiles:
Codex, OpenCode, ACPX Claude, and ACPX Codex. Changing an existing agent to
`paperclip_runner` remains supported only from `codex_local`; create the other
profiles explicitly after enabling the single **Paperclip Runner** experimental
setting. Onboarding continues to create legacy adapters. Disabling the setting
blocks fresh native starts without hiding or corrupting persisted native runs.
The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX
Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its
corresponding provider, preserves compatible models, credentials, workspace,
and instructions, and resets execution sessions while retaining run history.
Other adapters require an explicit provider choice. Legacy ACPX Codex agent
settings normalize to native Codex on configuration updates and before fresh
runs; immutable run descriptors remain readable. The **Paperclip Runner**
experimental setting and company access checks still apply.
Agent configuration uses the same section layout across adapters: model and
provider belong to **Adapter**, environment variables have their own section,
and command/extra arguments are folded under **Configuration → Advanced**.
Lifecycle, timeout, and interrupt grace settings live under **Advanced Run
Policy**. Permission selectors with a single valid mode are hidden; a saved
unsupported mode still exposes remediation.
Model catalogs and refresh follow the selected provider. ACPX Claude uses the
normal Claude catalog and accepts custom model IDs; the exact ID is sent to
Claude, which can reject unavailable models. Package/version verification is
independent of model selection. Environment tests verify runtime installation;
a successful provider run additionally verifies credentials and model access.
ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables.
On macOS the launcher uses private verified module/executable snapshots instead
of Linux `/proc` descriptors. Dependency isolation, process ownership, and
cancellation remain enforced; the snapshot is removed when the provider exits.
Native Codex is qualified only with `codexPermissionMode: "never"`. The create
and edit surfaces do not offer `on-request` or `untrusted`, and a persisted
@ -864,6 +961,14 @@ that classification finishes.
signal it or spawn a replacement.
- A persisted proposed or terminal result is reconciled before any runner or
provider work starts, so restart recovery cannot submit a duplicate turn.
- On the next run, a completed local Codex session whose warm controller died
before suspension is recovered automatically, including a uniquely verified
checkpoint quarantined by older controllers. Paperclip requires matching
database/session/provider identities, a settled terminal journal, no pending
commands or active provider turn, and a confirmed-dead process and process
group. It seals the old authority for normal epoch rotation and preserves the
Codex thread and goal state. Empty retry directories do not prevent recovery;
conflicting histories, changed profiles, and live or unverifiable owners do.
Run the credential-free real-process restart suite with:
@ -1279,3 +1384,35 @@ Networking behavior for this smoke script:
### GitHub identity for shared agents
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.
### Investigating polling load
The company heartbeat-run and live-run lists load secret registries in one
company-scoped query per response. Registry reads project only
`paperclipSecretRedactions` from the run context. They do not load the full
prompt/context JSON. Decrypted values live only for that request and each run
uses its own registry.
Hidden browser tabs suspend the company live-events connection and transcript
log reads. Returning to a visible tab refreshes active queries once and resumes
transcript reads from their retained offsets. A queued live-event invalidation
that flushes after the tab hides marks data stale without starting a refetch.
The developer-server health poll also stops in hidden tabs.
Workspace detail responses share concurrent Git inspections and reuse their
results for up to five seconds after completion. The cache holds at most 256
entries. Close-readiness checks, the terminal-workspace reaper, and the final
cleanup validation still inspect Git afresh. A display result never authorizes
worktree removal.
The connection-health sweep selects only due IDs in SQL before applying its
limit. Legacy `paperclip_plugin` placeholder connections are excluded: their
tools run in plugin workers and do not have remote MCP endpoints. These rows
remain available; the sweep does not disable or delete plugin connections.
When investigating an overloaded instance, distinguish request amplification
from stored configuration problems. Verify connection transport and endpoint
fields before disabling a connection. Verify workspace ownership, active runs,
Git state, and runtime-service readiness before closing a workspace. A missing
URL or old workspace timestamp alone does not prove that a row is disposable.

View File

@ -18,12 +18,55 @@ Build arguments:
|-----|---------|---------|
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
| `USER_GID` | `1000` | GID for the container `node` group |
| `CLI_TOOLS_CACHE_EPOCH` | empty | Refresh the CLI-install layer; CI supplies the current ISO week |
| `PAPERCLIP_BUILD_VERSION` | empty | Runtime version when Git metadata is unavailable |
| `PAPERCLIP_BUILD_COMMIT` | empty | Source commit written into the server build stamp and runtime environment |
Changing the build version or commit preserves the CLI-install cache. The
tool layer refreshes when its weekly epoch, base image, installation command,
or earlier build inputs change. Local builds can set a new epoch explicitly
to refresh tools without clearing the entire build cache.
```sh
docker build -t paperclip-local \
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
```
## Cloud image addresses
The Docker workflow publishes the managed deployment image for Linux AMD64.
`Cloud readiness` starts `Docker cloud` on each master push independently of the
multi-platform self-hosted build. Different commits use separate concurrency groups and existing
GitHub-hosted runners, so an older production or cloud build does not hold the
new commit in a workflow queue. Available GitHub runner capacity still applies.
Release tags and manual `Docker` dispatches call the same cloud build workflow.
Each commit exports to its own `buildcache-cloud-<FULL_SHA>` registry tag.
Builds import the current commit and nine first-parent ancestors, plus the
legacy `buildcache-cloud` fallback. This preserves reusable layers without
letting concurrent builds overwrite one shared cache manifest. Retain recent
cache tags if registry cleanup is configured; deleting them makes builds colder.
Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and
the checkout filesystem have at least 64 GiB available. Below that conservative
headroom threshold, or when the measurement fails, it retains the existing
cleanup. The threshold selects the fast path; it is not a new minimum disk
requirement for local builds or smaller runners.
After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its
commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha-<full-commit-sha>-cloud`.
This address lets commit-based deployment tooling reuse the normal build.
Existing short-SHA and release tags remain available.
The full-SHA tag identifies the source commit. It does not certify that source
tests passed or that a compatible database migrator is available. Deployment
tooling must still check those prerequisites and pin the resolved image digest;
a rebuild of the same source can update the tag's digest.
The separate [cloud readiness check](cloud-build-readiness.md) combines source
verification, successful cloud image checks, and exact-source migrator
availability. It runs outside the full npm release's concurrency queue.
## One-liner (build + run)
```sh
@ -295,3 +338,25 @@ Notes:
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).
## Native Runner build cache
The image compiles the native Runner in `runner-build`, before copying the
application source. That stage includes the pinned Rust compiler, the complete
Cargo workspace and lockfile, and the protocol schemas and fixtures embedded
by Rust. Changes to those inputs rebuild the native binary. Ordinary server or
UI changes can reuse it through the existing registry cache (`mode=max`). Each
platform gets its own native build; no cross-architecture binary is reused.
The application build inherits that stage and still runs the normal server
build, including Cargo, binary staging, and generated-contract checks. Rust
input file times are normalized in both stages so fresh checkouts do not force
Cargo to rebuild unchanged source. Changes made by build scripts still reach
Cargo's normal validation. The final application copy excludes Cargo's target
directory as before. Cache misses only cost compilation time.
Pull requests that change the Dockerfile, Docker ignore rules, or Runner native
inputs also build the isolated `runner-build` target in `Docker Runner check`.
This compiles against the actual reduced context and catches missing embedded
inputs before the post-merge image build. It uses a GitHub-hosted runner with
read-only repository access and does not publish images or cache artifacts.

View File

@ -61,6 +61,45 @@ the same origin as the artifact as an independent trust anchor.
Each installer flag also has a `PAPERCLIP_INSTALL_*` environment-variable
equivalent. This helps where passing arguments through a pipe is awkward.
Codex ACP workspace sessions enable networking so agents can report task outcomes.
To disable it explicitly, set `extraArgs` to
`["-c", "sandbox_workspace_write.network_access=false"]`, or set
`env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false"`. Execution-target network denial
also remains enforced. Read-only ACP mode remains read-only.
## Node runtime used by background services
Check the Node executable used by the running service, not only `node --version`
in an interactive shell. Systemd and launchd do not load shell version-manager
configuration. A newer Node installed elsewhere does not upgrade a running
service or change a custom startup script's `PATH`.
Managed installs pin the validated Node executable in the `paperclipai` shim
and prepend its directory to `PATH` for child tools, including ACP servers with
an `/usr/bin/env node` shebang. Re-run the installer using the supported
Node runtime after changing runtime installations, then restart the service.
For example, put the supported Node's bin directory first on `PATH` and run
`npx paperclipai@latest install --yes`. Do not use the old managed shim to
re-pin Node: it intentionally continues launching its previously pinned runtime.
Installs and updates refresh existing managed shims in place. Updates reject an
unsupported running Node before installing or activating a payload; read-only
update checks and rollback remain available for recovery. Global npm installs and
source checkout services must configure their own executable and child-process `PATH`.
For custom service wrappers, use an absolute, supported Node executable and put
that executable's directory first on `PATH`. Keep required existing PATH entries.
On Linux, verify the running executable with `/proc/<server-pid>/exe`; an
interactive shell version check alone is insufficient. Use the guarded restart
procedure in [DEVELOPING.md](DEVELOPING.md#hot-restart-deploys) when jobs are active.
Legacy local adapters default to ACP, including configurations with no `engine`
field or the old `auto` value. An unavailable ACP runtime fails the run and the
agent environment test with a setup error; it never silently changes engines.
Repair the reported prerequisite or explicitly select `engine: cli`. Local
filesystem/network confinement and in-place Codex workspaces require explicit
CLI selection. CLI sandbox defaults and explicit restrictions are described in
the adapter configuration documentation.
## Managed Install Layout
Managed code is separate from instance data:

View File

@ -335,3 +335,59 @@ Check:
- [doc/RELEASING.md](RELEASING.md)
- [doc/PUBLISHING.md](PUBLISHING.md)
- [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md)
## Runner verification dependency cache
`release-verify.yml` caches Cargo dependencies for its `Verify Paperclip Runner`
job using a pinned Rust Cache action. It selects the compiler from the Runner
package's `rust-toolchain.toml` before computing the cache key. Compiler and Cargo
metadata changes select a new cache; the `release-runner-v1` shared key lets
callers of this reusable verification workflow reuse the same dependency cache.
Workspace crates and installed Cargo binaries are excluded. Every run still
builds the Runner workspace and runs `check:all`, including the Rust and
TypeScript tests. Only an own-repository master-push run verifying that push's exact
SHA can restore the cache, and only a successful run saves it. PR, tag, and
manual candidate verification compile without this cache. A miss or eviction costs compilation time but does not change the checks.
To discard old dependency caches, increment the shared-key version and let the
next successful master verification warm it again.
The trust boundary is the protected master branch, not the cache-key text.
GitHub does not let master restore caches created by a child branch, sibling
branch, tag, or PR merge ref. Both permitted restore scopes (current branch and
default branch) are master here. A workflow with authority to execute arbitrary
code on master can affect verification directly and is already trusted. The
cache contains dependency build artifacts, not credentials or workspace output.
See [GitHub cache access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache).
## Chat integration test shards
Release verification runs the large chat integration file on three independent
runners. Five other server shards cover every remaining general server file.
The ordinary local test command and trusted PR workflow keep their complete
`general-server` group. Each chat case shuts down its services, pauses its own
still-active endpoints, and retires its active/waiting conversations after
assertions. This keeps workers in later cases from claiming earlier
fixtures in the shared test database. Application assertions stay unchanged.
Each chat job collects active tests with Vitest, groups cases by source line,
and balances those groups by case count. Parameterized cases and loop-generated
cases on one line stay together. The job re-collects with the exact line filters
it will execute and fails if the selected case identities differ. Hooks and test
execution remain sequential inside each runner with its own temporary home.
Run one shard locally with:
```sh
pnpm test:run:general -- --group general-chat --shard-index 0 --shard-count 3
```
Use indexes 0, 1, and 2 to run the complete chat suite. The CLI validates that
each shard has work and that collection includes usable source locations. A
Vitest collection or filtering change fails verification instead of dropping
tests. Splitting adds three release-verification jobs and repeats collection and
fixture setup; it does not make a single test faster.
The file-duration manifest also records the native Codex Runner integration
suite's measured import and execution cost, so the existing file balancer
accounts for it in both ordinary PR and release verification.

View File

@ -21,6 +21,9 @@ Paperclip V1 must provide a full control-plane loop for autonomous agents:
4. All work is tracked through tasks/comments with audit visibility.
5. Token/cost usage is reported and budget limits can stop work.
6. The board can intervene anywhere (pause agents/tasks, override decisions).
An effective task or ancestor pause replaces the message composer with an
amber Resume takeover. New board messages, including updates with comments,
are rejected until the hold is released. Drafts survive pause and resume.
Success means one operator can run a small AI-native company end-to-end with clear visibility and control.
@ -38,7 +41,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: continue interrupted local conversations with bounded fresh turns and preserved history, never replay tool calls automatically; retain native ownership and real execution gates; otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
@ -222,6 +225,14 @@ Invariant:
Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent.
Project source repositories use the existing `project_workspaces` collection.
Each selected GitHub repository has a canonical `repo_url` and stable provider ID
in `metadata.githubRepositoryId`; the first workspace remains the execution default.
The board can select multiple repositories from its usable personal and shared
GitHub grants. Selection does not grant runtime credential access. Legacy workspace
URLs remain valid. Project creation and repository replacement are transactional.
See `doc/project-repositories.md` for the API and UI contract.
## 7.6 `issues` (core task entity)
- `id` uuid pk
@ -242,6 +253,9 @@ Routine execution issues add a routine-scoped env overlay after project env and
- `created_by_user_id` uuid fk `users.id` null
- identifier fields: `issue_number`, `identifier`
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
- `request_depth` int not null default 0
- `work_mode` text not null default `standard`; supported values:
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
@ -1037,6 +1051,17 @@ instances return `404`.
- `GET /issues/:issueId/attachments`
- `GET /attachments/:attachmentId/content`
- `DELETE /attachments/:attachmentId`
- `GET /issues/:issueId/runner-goal?agentId=...`
- `POST /issues/:issueId/runner-goal/actions`
The runner-goal endpoints control an issue-scoped durable agent-session goal,
not a row in the company `goals` hierarchy. Reads return the effective agent,
negotiated capability, normalized goal snapshot, active-run state, pending
action, and revision. Mutations require a request id, assigned agent, expected
revision, and a negotiated action; they return `202`, replay the original result
for a duplicate request id, and return `409` with the current projection for a
stale revision or an unconfirmed unfinished-goal replacement. These controls do
not create issue comments.
### 10.4.1 Atomic Checkout Contract
@ -1163,6 +1188,17 @@ interface AgentAdapter {
}
```
### Local adapter engine availability
For the legacy Codex, Claude, Gemini, and Kimi local adapters, an omitted engine
or legacy `auto` value selects ACP deterministically. Missing prerequisites or
ACP execution failures fail the run; they must not launch a different engine
with different session, permission, or sandbox semantics. CLI execution requires
explicit selection. Environment tests report the same engine availability error
as execution. Codex CLI defaults permit workspace writes and network access for
Paperclip coordination without disabling its sandbox; explicit operator
restrictions and execution-target network denials remain effective.
## 11.2 Process Adapter
Config shape:
@ -1231,6 +1267,35 @@ Scheduler must skip invocation when:
- an existing run is active
- hard budget limit has been hit
## 11.7 Durable agent session goals
Runner Protocol v2 negotiates a required `sessionGoals` capability and typed
`session.goal.*` commands and events. PRP v1 sessions remain supported and are
goal unsupported. The Codex app-server driver maps controls to
`thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`; it observes
provider-created goal notifications and reconciles with an authoritative get
after each turn. An active goal suppresses premature run terminalization while
autonomous turns continue. The Paperclip runner's persistent ACP backend opts
in through the `_session/goal` extension and advertises its exact action subset.
Its pinned Codex/Claude executables retain the runner's Linux x64 qualification
requirement. Direct `codex_local` and `claude_local` adapters currently have no
live goal controller and remain unsupported, even when their underlying ACP
package exposes goals. Goal actions never change an agent's adapter, model,
permission policy, or rollout settings to manufacture support. CLI, one-shot
ACP, and providers without the structured extension remain unsupported.
When a goal heartbeat settles, the runner suspends its durable authority even
under a warm lifecycle policy. Paused, blocked, completed, and rollover goals
must survive controller restart without relying on an in-memory warm owner.
The next run resumes the same provider session through the existing verified
checkpoint and authority-rotation path.
The board composer treats `/goal` as an action command rather than Markdown or
comment text. It is capability-aware, and the issue thread renders durable goal
status and controls immediately above the composer. Goal completion enters the
normal run-result/completion arbitration path and does not directly close the
issue.
## 12. Governance and Approval Flows
## 12.1 Hiring
@ -1259,6 +1324,22 @@ Board can at any time:
- edit budgets and limits
- approve/reject/cancel pending approvals
## 12.4 Connection Tool Reviews
Ask-first connection calls use a server-owned tool-action confirmation linked to
the authoritative action request. The task feed retains a stable record; dismissal
only hides the composer takeover. Task and Connections decisions share one
transaction. Approval runs stored, signed arguments once; decline runs nothing.
The human decision remains distinct from provider execution success or failure.
Always allow remembers the same agent, connection, and action, restricted to the
current project when present, with future argument values permitted. Explicit
denials, revoked access, catalog-definition changes, and formal approval gates
remain effective. A durable continuation receipt resumes eligible task context
with the recorded outcome after the agent yields. Uncertain interrupted execution
is surfaced without automatic replay. See [Task reviews](connections/TASK-REVIEWS.md)
for contracts, recovery behavior, Storybook, and acceptance workflows.
## 13. Cost and Budget System
## 13.1 Budget Layers
@ -1492,3 +1573,12 @@ Export/import behavior in V1:
- import supports preview (dry-run) before apply
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
- GitHub imports warn on unpinned refs instead of blocking
### User messages after native execution recovery stops
An authenticated user message can start a fresh native conversation turn once
the prior execution is confirmed stopped. Retain the source history and uncertain
action outcomes; do not replay tool calls or reset the failed incident's automatic
retry budget. Existing pause, approval, budget, ownership, and dependency gates
remain in effect. See `doc/execution-semantics.md` for admission and stop-proof
requirements.

View File

@ -29,6 +29,11 @@ Every Company has a **Board** that governs high-impact decisions. The Board is t
- CEO's initial strategic breakdown (CEO proposes, Board approves before execution begins)
- [TBD: other governance-gated actions — goal changes, firing Agents?]
Connection tool reviews also appear in task history, with a composer takeover for
human approval, decline, or scoped remembered permission. Connections and task
views resolve the same review, and the agent continues with the server-recorded
outcome. See [the implementation contract](SPEC-implementation.md#124-connection-tool-reviews).
#### Board Powers (Always Available)
The Board has **unrestricted access** to the entire system at all times:
@ -171,6 +176,11 @@ When a task originates from a cross-team request, track the **depth** as an inte
#### Billing Codes
Task detail keeps hierarchy separate from creation provenance: the Tasks tab shows
all subtasks and, independently, work created from the current task grouped by
project or No project. A created subtask may appear in both sections. Creation
provenance follows the originating run equally for legacy and native runners.
Tasks carry a **billing code** so that token spend during execution can be attributed upstream to the requesting task/agent. When Agent A asks Agent B to do work, the cost of B's work is tracked against A's request. This enables cost attribution across the org.
### Open Questions
@ -204,6 +214,12 @@ Agent configuration includes an **adapter** that defines how Paperclip invokes t
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
An adapter's selected execution engine is part of its permission and session
contract. Missing prerequisites or engine failures must be surfaced without
silently launching a different engine. A default local engine must support
normal task work and control-plane coordination; explicit operator restrictions
remain authoritative.
### Adapter Interface
Every adapter implements three methods:
@ -532,3 +548,10 @@ Things Paperclip explicitly does **not** do:
7. **Atomic ownership.** Single assignee per task. Atomic checkout prevents conflicts.
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
### Paused task messages
A paused task takes over the composer with an amber notice and a Resume action.
Operators must release the effective task or ancestor pause before sending a new
message. The draft stays intact. This applies to both task interfaces and to
board comment API requests; an agent may still report interrupted work.

View File

@ -0,0 +1,87 @@
# Storybook branch hosting
The `Storybook Deploy` workflow publishes public static Storybook builds to the
existing private S3 bucket behind CloudFront. It does not deploy to GitHub Pages.
## Current destination
- AWS account: `078455283791`, region `us-east-1`
- Bucket: `paperclipai-runner-e2e-history-078455283791-us-east-1`
- Allowed upload prefix: `storybook/branches/`
- Distribution: `E3GTU28BBO2SFR`
- Public origin: `https://d1p6rlowie26tp.cloudfront.net`
- Role: `arn:aws:iam::078455283791:role/paperclip-storybook-github`
The distribution's default behavior disables edge caching and rewrites directory
URLs to `index.html`. Stable branch indexes send `no-cache`; unique build objects
send `immutable`. No invalidations or CloudFront write permissions are needed.
Each publication updates a readable bookmark, such as
`https://d1p6rlowie26tp.cloudfront.net/storybook/branches/master/`, after the
immutable build upload completes. It also updates the previous hashed branch
entry for compatibility. The run summary and Markdown artifact link the bookmark.
Branch names use one escaped path segment, preserving case and separating slashes
from hyphens; see [the branch publishing guide](DEVELOPING.md#publish-a-branch-storybook)
for the encoding. No additional AWS permissions or distribution changes are needed.
## GitHub configuration
Create environment `storybook-deploy` with required reviewers set to the
individual CODEOWNERS accounts. Disable administrator bypass, allow self-review,
and allow repository branches. Keep these reviewers synchronized with CODEOWNERS.
The workflow rejects environments with no required reviewers, non-owner reviewers
or administrator bypass enabled. The AWS role trusts only this repository and
this environment, so a branch cannot obtain upload access through an unprotected
environment.
Set repository variables:
| Variable | Value |
| --- | --- |
| `STORYBOOK_AWS_ROLE_ARN` | `arn:aws:iam::078455283791:role/paperclip-storybook-github` |
| `STORYBOOK_AWS_REGION` | `us-east-1` |
| `STORYBOOK_S3_BUCKET` | `paperclipai-runner-e2e-history-078455283791-us-east-1` |
| `STORYBOOK_PUBLIC_BASE_URL` | `https://d1p6rlowie26tp.cloudfront.net` |
No stored AWS access keys are needed. Leave the runner dashboard variables and
GitHub Pages configuration unchanged.
## Operator setup
Use the `paperclip-dev` operator AWS profile. Review the checked-in policies in
`.github/storybook-deploy/` before applying them. The existing GitHub OIDC provider
must be present in this account.
```sh
aws sts get-caller-identity --profile paperclip-dev
aws iam create-role --profile paperclip-dev \
--role-name paperclip-storybook-github \
--assume-role-policy-document file://.github/storybook-deploy/trust-policy.json
aws iam put-role-policy --profile paperclip-dev \
--role-name paperclip-storybook-github --policy-name StorybookBranchUpload \
--policy-document file://.github/storybook-deploy/upload-policy.json
```
For an existing role, use `update-assume-role-policy` instead of `create-role`.
Add the statement from `cloudfront-read-statement.json` to the existing bucket
policy's `Statement` array. Preserve every other statement, including the HTTPS
requirement and runner report access. Keep all S3 public-access blocks enabled;
only CloudFront receives read access to this public-content prefix.
The role has no delete, bucket policy, IAM, CloudFront, or root-object permissions.
The workflow never runs `sync --delete`. Builds accumulate; any retention cleanup
must preserve the build referenced by each branch entry.
## Verification
```sh
node --test scripts/__tests__/storybook-deploy.test.mjs
actionlint .github/workflows/storybook-deploy.yml .github/workflows/storybook-visual.yml
```
Dispatch two source branches, approve each deployment, and check their distinct
branch URLs and each build's `deployment.json`. Redeploy one branch and confirm
its stable URL now points to the new build while the other branch is unchanged.
The publisher checks the public build metadata against the selected source SHA
and verifies that the public branch entry points to this exact build. It retries
brief propagation delays and fails if the branch URL remains stale.

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,147 @@
# Cloud build readiness
The `Cloud readiness` workflow starts for every master push. Its versioned
`Cloud deployable v1` job succeeds only after all three prerequisites succeed:
- The existing `Release Verify` workflow checks that exact commit, including
typecheck, builds, general and serialized tests, and Runner verification.
- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64
image, including Sentry resolution and orphan reaping, then publishes the
full-SHA cloud tag. Cloud readiness owns the master trigger so there is one
cloud build per push. Release tags and manual Docker runs retain their callers.
- The full-SHA image and both exact-source npm packages are visible. The
packages are `@paperclipai/shared` and `@paperclipai/db` at
`0.0.0-preview.g<FULL_SHA>`, published through the migrator-only release lane.
Registry metadata must match the full commit, and the database package must
pin the matching shared package.
The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`,
matching the managed runtime. This avoids a startup user remap, which can walk
the mounted home and delay health checks. Before publishing the full-SHA tag,
the workflow checks the baked identity without running the entrypoint, then
checks the normal entrypoint's effective user and writable home. Volume ownership
repair still runs when needed. The Dockerfile defaults remain `1000:1000` for
self-hosted builds, and runtime identity overrides remain supported. The first
build with the new identity must rebuild layers that depend on the base image;
later builds can reuse those layers.
Verification and image building run concurrently, outside the full npm release's
concurrency group. Different commits have independent groups. The npm canary
release reuses `Cloud source verified v1` for the exact master push instead of
starting a second copy of `Release Verify`. This source-only job depends on every
source check but does not wait for Docker or migrator publication. npm canary
publication remains possible when source verification passes and an image build
fails. Stable releases and candidate-branch betas still run full verification.
The canary consumer requires the expected workflow ID and path, upstream source
repository, master push event, full SHA, and a successful job in the latest run
attempt. It checks the run again after reading the jobs to reject a concurrent
rerun. Missing proof waits for up to 45 minutes; failed, skipped, cancelled,
ambiguous, or mismatched proof cannot authorize publication. API failures fail
closed. If a source check fails, fix it and rerun Cloud readiness before retrying
the release. Use **Re-run all jobs** when a later attempt did not rerun the source
proof; an earlier attempt's successful job is not accepted. This avoids duplicate
test jobs on standard runners. Measure queue time to assess the timing gain.
Release verification spreads the general server suites across ten standard hosted
runners, with the long chat suite split separately across three jobs. Each server
job still runs one test worker. The partition covers every suite exactly once;
normal PR and local test groups keep their existing shape. More jobs increase
concurrent runner demand, so compare queue time as well as test duration.
The artifact wait runs for up to 30 minutes and reports what is missing. Only
an HTTP 404 means publication is pending; authorization errors, upstream outages,
and identity mismatches fail the job. A failed, cancelled, or skipped prerequisite
cannot produce a successful readiness job. Retry the failed publication or build,
then rerun the failed readiness workflow jobs to check the same commit again.
## Consumer contract
`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment
consumer must still resolve and pin the image digest and npm integrity/lockfile,
validate migration contents and compatibility, and apply its target health gates.
The check creates no release record and deploys no instance. A full-SHA tag by
itself, or a successful migrator dispatch, is not this readiness signal.
For automatic selection, accept only a successful job named exactly
`Cloud deployable v1` in the latest attempt of a successful
`.github/workflows/cloud-readiness.yml` run in `paperclipai/paperclip`, with
event `push`, head branch `master`, and the expected full head SHA and repository.
Do not trust a similarly named check from another workflow or a manual branch run.
Order candidates by master ancestry, not job completion time: an older commit
finishing late must not roll a fleet backward. Fail closed on API errors.
Existing npm canary discovery is unchanged by this producer workflow. Consumers
can adopt the versioned signal separately after the workflow has landed and
successfully verified a real master commit.
## Timing and rollout
The reusable Runner chaos workflow scopes concurrency to the caller workflow
and source ref. Cloud readiness, stable verification, and standalone evals can
verify the same commit at the same time. They must not cancel each other's
required test job.
Measure the complete path from a master merge to a healthy target running that
exact commit. Keep readiness and deployment as separate milestones:
| Milestone | Evidence | Elapsed time starts at |
| --- | --- | --- |
| Merge | Merged PR timestamp and full merge commit SHA | Merge |
| Image available | Successful full-SHA image publication and verification | Merge |
| Cloud deployable | Successful `Cloud deployable v1` job in the accepted push run and attempt | Merge |
| Canary healthy | Deployment consumer's canary health gate confirms the target commit | Merge |
| Fleet complete | Campaign succeeds for all eligible targets at that commit | Merge |
Record the source SHA, workflow run ID and attempt, readiness job completion
time, and deployment campaign identity together. Verify the run against the
consumer contract above. A manual dispatch can test wiring, but its timestamp
does not measure automatic merge-to-deploy latency. A preparation-only run
resolves artifacts without deploying a target and must not be counted as a
successful deployment.
Record queue time and the image, source-verification, and artifact-wait durations
separately. The slowest prerequisite determines readiness; shortening an already
faster prerequisite may have no effect on the total. After readiness, measure
consumer discovery delay, artifact resolution, canary health, and fleet rollout.
An automatic consumer that still waits for the full npm canary publication has
that queue on its critical path even if cloud artifacts are ready earlier.
For a target health measurement, confirm the deployed source SHA as well as
service health. A proxy health response alone may describe the control plane
while the tenant still runs the previous image. Report the eligible target count,
excluded or sleeping targets, retries, and failures with the fleet result. Record
runner queue conditions and cache state; one warm or cold run is a sample, not a
latency guarantee.
Land full-SHA image publication, independent cloud builds, and migrator-only
publication before enabling this workflow. Until those producers are present,
the artifact wait cannot succeed. A manual dispatch on master can verify the
wiring, but automatic consumers should use push runs. Source verification and
registry checks can be rerun without deploying or changing mutable npm channels.
When reverting this workflow, restore the master push trigger in
`docker-cloud.yml` in the same change so master images continue to build.
## AWS cloud build routing
`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the
`paperclip-cloud-build-x64` RunsOn Fleet for canonical `paperclipai/paperclip`
master pushes and manual master runs. Forks, pull requests, and release tags
retain GitHub-hosted runners. The separate `AWS_CI_ENABLED` and
`AWS_CI_TRUSTED_USER_IDS` variables control PR routing.
The cloud Fleet uses a separate runner group, `paperclip-cloud-build`, restricted
to this repository and `.github/workflows/docker-cloud.yml@refs/heads/master`.
Provision that group and Fleet before enabling the variable. The cloud runners
need at least 64 GiB free for Docker and the workspace; the initial configuration
uses 120 GiB disks with the existing 4-vCPU, 16-GiB machine size. AWS jobs have
a 40-minute workflow timeout so they finish before the 45-minute instance
lifetime; GitHub-hosted jobs retain their 60-minute timeout. Keep the registry
cache and all pushed-image verification steps enabled.
To roll back routing, set `AWS_CLOUD_BUILDS_ENABLED=false`, then rerun the cloud
workflow. Changing the variable does not migrate an already assigned job.
Check the Actions job's runner name and runner group to verify placement. Record
queue time, image verification completion, and `Cloud deployable v1` separately;
source verification and the migrator still run on GitHub-hosted runners.

42
doc/cloud-ui-snippet.md Normal file
View File

@ -0,0 +1,42 @@
# Cloud UI snippet
Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
The server inserts it before `</body>` in static and Vite-served UI pages.
It requires the existing Cloud-managed instance signal. Self-hosted instances
ignore this setting. No snippet is enabled by default.
This is trusted deployment configuration, not user input. It executes in the
application origin and is visible to every browser that receives the UI shell.
Do not include secrets or customer data. Restart the app after changing it.
Operators must review scripts and any required CSP changes before deployment.
## Plain closed beta
Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the
public chat app ID for the target environment:
```html
<script>
(function(d) {
var script = d.createElement('script');
script.src = 'https://chat.cdn-plain.com/index.js';
script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); };
d.head.appendChild(script);
})(document);
</script>
```
No signing secret or Plain API key is required. No Paperclip customer identity
or organization data is passed. Plain manages the anonymous browser session;
there is no Paperclip account-switch integration. Ask users for identifying
information when needed. The existing feedback flag remains unchanged.
Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat).
## Verification and rollback
On staging, open `/`, `/index.html`, and an organization dashboard directly.
Confirm the bubble appears and a test message reaches Plain. Verify the support
reply returns. On a self-hosted instance, confirm no snippet or widget is loaded.
Unset the snippet and restart to remove it on the next page load. Existing open
tabs retain the widget until refreshed. No production deployment is implied.

212
doc/composer-stop.md Normal file
View File

@ -0,0 +1,212 @@
# Composer Stop and task controls
The empty composer shows **Stop** while this task has a live execution and the
viewer can manage task controls. Stop creates the same manual pause hold as
**Pause work** / **Pause subtree** in the task menu. A parent pause includes its
descendants. Cancellation remains a separate menu action.
Text (after trimming) or attachments switch the button back to Send. Uploading
and failed attachments retain their existing restrictions. Keyboard submission
never invokes Stop. Queued-message editing and structured interactions keep
their existing actions, and text entered while stopping remains in the draft.
Pause dispatches without a preview dialog or reason. The button stays pending
while affected run state is checked; native cancellation must be acknowledged.
A saved hold with unconfirmed termination produces an error rather than a
success claim. The cancellation dialog requires a valid preview and excludes
terminal tasks. Resume/restore retain the optional wake-agents checkbox.
## Resume and execution recovery
Resume releases a hold. Waking agents is optional and only applies to tasks in
`todo`, `in_progress`, or `in_review`; parked and terminal tasks stay untouched.
The current execution-recovery policy requires verified outcomes before a stopped
provider can restart. If any affected task still needs that reconciliation,
Resume with wake enabled returns an inline error and preserves the pause. The
operator can release the pause without waking agents, then use the existing
execution-reconciliation flow after reviewing the stopped run. Resume does not
claim that unknown provider actions completed or were never performed.
A completed release remains successful if a best-effort wake fails. Its response
includes optional `wakeFailures`, the page reports them inline, and remaining
eligible tasks still receive their wake requests. No new endpoint is introduced.
The deterministic E2E fixtures prove interruption, then record their known lack
of external effects through the existing reconciliation API before continuing.
Embedded ACP also supports verified continuation of an interrupted local session.
The adapter must acknowledge cancellation and prove a preserved session with
settled read-only work. Stop waits for provider cleanup. Forced local termination uses the actual
child-process handle captured at spawn, including on Windows. An unavailable
handle does not authorize a signal or replay. Unknown actions remain
blocked, and task detail shows the reason even after recovery bookkeeping resolves.
A run-level Stop leaves the task unpaused; a subsequent comment can continue the
same session with the earlier queued messages. Composer Stop still creates a
pause hold. New board messages require Resume first. Both comment creation and
updates that include a comment return `409` while an effective task or ancestor
pause hold is active. Interrupted agents may still report their results. Neither path permits a fresh-session fallback
when the interrupted checkpoint cannot be restored.
The credential-free ACP regression journey uses an actual ACP child process:
```sh
pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/acp-stop-continuation.spec.ts
```
It covers the queued-follow-up sequence (queue a second request, stop, then send “go”),
same-session delivery of both messages, and an unfinished write that stops
mutating its file but retains a visible execution blocker. Unit and integration
tests additionally cover pre-start Stop, unavailable/changed sessions, rotating
scratch directories, cancellation acknowledgment, a provider that hangs during
cleanup after returning cancellation, deferred-wake adoption, and
company-scoped blocker lookup. Hosted-provider behavior is a separate smoke test.
The browser tests also require the continued provider to complete the task through
the agent API. Restoring a session must refresh its run identity, API credential,
and scratch environment. The same conversation must not reuse the stopped run's
credential. A regression test checks distinct run IDs and token hashes across the
restart without logging the credentials themselves.
Historical behavior, superseded by the composer takeover: on 2026-09-09, all three ACP browser journeys passed. A manual browser walk-through
also queued a request, used composer Stop, sent “go” while paused, and selected
Resume work. The pause stayed in place during the conversation reply. Resume
restored the same provider session, answered the pending request once, and moved
the task to Done through the current run's authenticated API call. These tests use
a deterministic ACP child process; they do not call Drive or another external app.
The manual unfinished-write check also confirmed that file size stayed unchanged
for five seconds after Interrupt. Sending “go” displayed the reconciliation reason
and did not start another provider prompt.
A separate live Claude ACP smoke test interrupted a Bash tool writing only to a
disposable local file: cancellation settled in 1,167 ms, output remained unchanged
for five seconds, and no matching tool process remained. The shell action correctly
did not receive automatic replay permission. A second live Claude check interrupted
a response with no tools, restored the exact same provider session, and received
the requested follow-up answer. These are local-provider observations, not a
latency guarantee or proof for every provider and remote sandbox.
## Quiet task feedback
The visible task/subtree does not produce duplicate state toasts. Its live
notifications are suppressed while foregrounded, including descendant runs;
unrelated and background work retains notifications. Tree-control results use
inline state, and failures stay in the composer, page, or confirmation dialog.
The amber composer takeover contains “Subtree is paused.” (or “Task is paused.”),
a short instruction to resume before sending, and Resume. It replaces input
controls in both task interfaces, cannot be dismissed, and preserves text and
attachment drafts. An inherited hold links to the ancestor task. Pending resume
keeps the takeover visible; failed resume leaves the task paused. Expected cancellation uses a muted gray disclosure with optional details.
This is recorded as a product rule in `DESIGN.md`.
The follow-up passed 213 focused tests, both isolated runner E2E journeys
(including no-toast assertions), UI typecheck/build, token gates, and Storybook
build. Paused, expanded cancellation, Stop without toasts, mobile, and light
stories were inspected in the browser.
## Storybook
Run from the worktree:
```sh
pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open
```
Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`.
The `Tasks / Execution Controls` stories compose the production composer and
menu/dialog controls together. They cover text switching, attachment-only,
idle, stopping, paused, errors, cancellation preview/loading, and mobile/light
presentations. The Storybook state transitions simulate requests; runner
verification belongs to the isolated browser suite below.
## Automated verification
```sh
pnpm exec vitest run --project @paperclipai/ui ui/src/components/task-chat/TaskChatComposer.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/lib/wait-for-stopped-runs.test.ts
pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-tree-control-routes.test.ts
pnpm check:token-gates
pnpm build-storybook
pnpm -r typecheck
pnpm test:run
pnpm build
```
The component/page tests cover whitespace, attachments and upload restrictions,
permissions, keyboard submission, queue edits, duplicate clicks, draft
preservation, shared pause requests, compact cancellation, and visible errors.
Stop verification tests include continued execution, native acknowledgment,
network failures, and hung status requests. Route tests cover pause dispatch,
authorization, cancellation, and opt-in resume wakeups with terminal/company
exclusions.
## Isolated browser acceptance
Legacy process coverage needs no provider credentials:
```sh
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
```
To include native execution, build the real runner and deterministic Codex
protocol fixture from this checkout, then point the suite at their absolute
paths:
```sh
cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin paperclip-runnerd --bin fake-codex-app-server
PAPERCLIP_STOP_FAKE_CODEX="$PWD/packages/paperclip-runner/runner/target/debug/fake-codex-app-server" \
PAPERCLIP_RUNNER_BINARY="$PWD/packages/paperclip-runner/runner/target/debug/paperclip-runnerd" \
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
```
The suite boots a disposable local-trusted instance on port 3199 (override with
`PAPERCLIP_E2E_PORT`). It never attaches to an existing server. Native coverage
is explicitly skipped without the fixture; it must not use a logged-in provider
as a fallback. Test companies are archived during cleanup.
For each runner, the journey starts a parent, child, and unrelated task, plus a
terminal child. It sends while running and verifies the durable queue, clicks
Stop, verifies interruption and the persisted hold, and observes three
ten-second scheduler intervals without continuation. It resumes with wakeups,
pauses from the menu, dismisses and then confirms cancellation, and verifies
terminal-task exclusions and unrelated execution.
Timing attachments distinguish click-to-request from request-to-observed-stop.
Native proof requires provider `turn/start` before the click, `turn/interrupt`
after it, and durable cancellation acknowledgment. Legacy proof checks the
actual parent and child PIDs have exited with a one-second configured grace
period. HTTP success alone is insufficient.
For a release with real providers, repeat while an actual long-running tool is
active, confirm the tool's own process/output stops, and verify a queued message
is consumed on continuation. The deterministic provider exercises the native
transport and cancellation protocol, not external provider behavior or every
tool's process cleanup. Preserve these limits in any test report.
## Recorded acceptance run (2026-09-09)
Both isolated browser journeys passed. Legacy Stop dispatched after 214 ms and
observed both runs stopped 155 ms after dispatch; native dispatched after 212 ms
and observed stop 320 ms later. These are single local observations including
browser automation overhead, not latency guarantees. Native used the real
runnerd and the repository's deterministic Codex protocol fixture. Real hosted
provider/tool cleanup remains a release acceptance check.
Repository typecheck, production build, Storybook build, token gates, targeted
UI tests, and tree-control route tests passed. The broad UI run passed 5,491
tests with one five-second timeout in an unchanged `IssuesList` test; rerunning
that file passed all 46 tests. The repository test run encountered 17 failures
in unchanged suites, all reproduced in the original checkout:
- `server/src/__tests__/workspace-runtime.test.ts`: 2 failures.
- `server/src/services/workspace-runtime-exposure.test.ts`: 7 failures.
- `server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts`: 4 failures.
- `server/src/__tests__/workspace-instance-cleanup.test.ts`: 1 failure.
- `server/src/__tests__/company-skills.test.ts`: 2 failures.
- `server/src/__tests__/worktree-seed-server-spawn.test.ts`: 1 failure.
These baseline failures prevent a green repository-wide test result.
The broad run was stopped after more than 30 minutes in its serial server lane
once these failures were independently reproduced. Later full-suite groups
did not run. The full UI suite and the feature's server route suite were run
separately as described above.
The `Tasks / Composer / Paused task takeover` stories use the production composer
and cover task/subtree holds, saved drafts, resume progress/failure, and light/mobile layouts.

View File

@ -11,14 +11,18 @@ Connection intents let an agent ask the responsible user for a known service con
Provider-specific setup must stay in the shared feature and `AppDefinition` metadata. Do not add provider forms or connection mutations to either host.
When a task connection needs Paperclip Cloud enrollment, the shared dialog opens enrollment in a separate window. The task keeps its access selection and interaction ID. A new-tab link is available if the window does not open. The dialog reads server enrollment status and refreshes the provider catalog after approval; enrollment alone does not mark the app connected. OAuth retains the interaction ID even if setup resumes in the page host, so the verified callback can resolve the task card and queue its continuation.
## Agent tools
Every active heartbeat with a responsible user receives two run-bound tools:
- `connections_search({ query })` searches first-party connectable definitions and returns `ready`, `needs_user_action`, `available`, or `unavailable` from the requesting agent's perspective.
- `connection_request({ service })` returns immediately when the service is already usable. Otherwise it creates or reuses a `connection_intent` and instructs the agent to end the run pending continuation.
- `connections_search({ query })` searches catalog names and descriptions, plus authorized configured MCP connections and indexed tool descriptions and returns `ready`, `needs_user_action`, `available`, or `unavailable` from the requesting agent's perspective.
- `connection_request({ service })` returns immediately when the service is already usable. Otherwise it creates or reuses a `connection_intent` and instructs the agent to finish independent work, then yield pending continuation.
Claude and Codex receive the tools through a native managed MCP server. Local/process adapters receive `PAPERCLIP_RUNTIME_TOOLS_*` environment variables and CLI guidance. Cloud, HTTP, gateway, and external adapters receive the typed runtime descriptor in their invocation context; compatible adapters may also project it into their remote environment.
The native Paperclip Runner advertises both tools through its server-owned tool authority even with an empty MCP assignment. It captures the current responsible identity at each call. Legacy Claude and Codex receive the tools through a managed MCP server. Local/process adapters receive `PAPERCLIP_RUNTIME_TOOLS_*` environment variables and CLI guidance. Cloud, HTTP, gateway, and external adapters receive the typed runtime descriptor in their invocation context; compatible adapters may also project it into their remote environment.
Legacy delivery uses the same intent service, setup card, and fresh-session resolution wake. Environment and descriptor delivery require the receiving harness to consume them; they do not establish support in every third-party runtime. The default legacy prompt includes the canonical discovery guidance. A custom `promptTemplate` replaces that default and should retain the connection guidance if proactive discovery is desired.
The equivalent CLI helpers are:
@ -31,14 +35,46 @@ The manually configured Paperclip MCP server also advertises `connections_search
## Security and lifecycle
- Company, agent, run, task, and responsible user come only from the signed runtime token and stored heartbeat context.
- Company, agent, run, task, and responsible user come only from the signed legacy runtime token or the native server-owned binding and stored execution identity.
- Tokens are scoped to connection intents, expire after one hour, and are rejected when the heartbeat is no longer running.
- The thread payload contains only service identity, requesting-agent identity, and a safe phase. It never contains credentials or authorization URLs.
- OAuth state is linked to the interaction. The same-origin callback finalizes the existing connection pipeline, posts only interaction ID/outcome to its opener, and redirects back to the task if there is no opener.
- Personal OAuth defaults to the addressed user and creates an explicit delegation to the requesting agent. Reuse and installs are additive.
- Task-hosted setup locks install reach to the requesting agent; the store host retains its normal broader access choices.
- The intent resolves only after the connection, grant/delegation, profile access, and install succeed. Failures remain pending with `needs_retry`.
- Closing the task, a newer run requesting the same service, or a newer human task comment expires the intent and deletes linked OAuth state.
- Success and decline wake the assignee once using an interaction-and-status idempotency key and force a fresh continuation session.
- Closing or reassigning the task expires pending intents and deletes linked OAuth state. Ordinary comments and later runs preserve the pending card. Requests reuse the same task, requester, addressed user and service; a different addressed user supersedes an older request for that service.
- Success and explicit decline atomically persist a continuation delivery with resolution. A leased startup/periodic worker dispatches through heartbeat with a unique `connection-intent:<interaction>:<outcome>` wake key. It checks assignment, status, membership and current executable access, retries paused/suppressed delivery, and recovers a crash after enqueue without creating a second wake. The continuation forces a fresh provider session; heartbeat queues it behind active execution.
Legacy `request_confirmation.payload.connectionAuthorization` interactions remain readable and resolvable. New agent requests use `connection_intent` exclusively.
## Model evaluations
The companion `paperclip-evals` repository owns the connection cases in
`evals/runner-api-tools/connection-cases.json`. They use the existing real-server
API-tool eval controller and `scripts/runner-api-eval-worker.ts`, with this
checkout's production tool definitions, connection guidance, and authority.
Natural prompts measure discovery and request selection; explicit contract probes
measure deduplication, readiness, and denied targets. The fixture helper supplies
isolated company records and retains initial and final connection interaction
state. Scoring requires observed calls/results and persisted state, not an
assistant's claim of having connected.
These tool evals do not perform live provider OAuth or establish browser quality.
The native and legacy connection browser suites separately cover setup and
continuation, while the live-provider journey report records actual authorization
and data-read coverage.
## Custom targets, readiness and recovery
Catalog slugs remain stable. Search also returns `connection:<uuid>` for configured custom connections whose active identity grants authorize the responsible person, their company, or the requesting agent. Identifiers are never interpreted as URLs. Configured metadata and tool descriptions, including catalog-provider descriptions, are read only after the company and identity audience checks. Setup choices expose only display and selection metadata; they never include connection configuration, transport settings, or credential fields. Discovery reads the stored index without refreshing providers. Search is ranked with exact provider matches first and capped at 20 results.
`ready` requires an installed, enabled, healthy executable connection, permitted catalog tools, and a usable runtime identity. An installed connection with denied actions is administrative denial rather than a request to reauthenticate. Runtime calls continue enforcing access after a historical card resolves. If access is ready but the native provider's pinned tool snapshot is older, `connection_request` queues a fresh session without another authorization card.
Task setup defaults to personal identity when supported and the requesting agent's install reach. Existing installs are additive. An OAuth callback from a task prepares the catalog without adding access. Intent completion validates the current task and identity, then adds the requesting agents binding and install in the resolution transaction. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
## Verification
Service and native-authority tests cover discovery, current identity, company boundaries, cross-run deduplication, additive grants and installs, permission denial, resolution atomicity, restart delivery and stale assignment. `tests/e2e/in-feed-native/playwright.config.ts` starts source `test-drive` instances with fresh data directories and a deterministic fake Codex provider plus MCP server. It exercises the real native runner and gateway; it is fixture proof, not live Notion or GitHub proof. Run with `pnpm exec playwright test -c tests/e2e/in-feed-native/playwright.config.ts`.
Offline Storybook examples live in `ui/storybook/stories/in-feed-connections.stories.tsx`. Build with `pnpm --filter @paperclipai/ui build-storybook`, then run `pnpm exec playwright test -c tests/storybook-visual/in-feed-connections.config.ts`. The suite checks every independently addressable story in both themes, catches play-function failures, and saves screenshots. Live provider acceptance additionally requires a model credential and a test workspace/account; do not describe fixture results or local-trusted testing as authenticated/cloud acceptance.

View File

@ -0,0 +1,127 @@
# Connection review verification — 2026-09-08
Implementation workspace: `/Users/dotta/paperclipai/branches/codex/reviews-in-task`.
Branch: `codex/reviews-in-task`, rebased on `master` at `8f099c3f8`.
The original verification below predates that rebase; final checks are recorded in the PR.
## Acceptance status
The deterministic integration paths demonstrate both synchronization directions
and scripted-agent continuation. A real native Codex approval and continuation
also passed in the local test drive, as recorded below. Live Notion and the
remaining real model-runner journeys are **untested dependencies**. The PR records
the final repository and CI check results.
## Inspect the UI
Storybook is running from this worktree on port 6018:
- [Interactive task](http://localhost:6018/?path=/story/chat-comments-connection-reviews--interactive-task)
- [All card states](http://localhost:6018/?path=/story/chat-comments-connection-reviews--all-states)
- [Connections queue](http://localhost:6018/?path=/story/chat-comments-connection-reviews--connections-queue)
- [Narrow layout](http://localhost:6018/?path=/story/chat-comments-connection-reviews--narrow)
Manual browser inspection covered light/dark presentation, the split approval
menu, keyboard approval, and dismissal/reopening. The final card shows only the app
icon, request description, and decision controls. Optional labels and details were
removed. Narrow controls fit without horizontal clipping.
The fixture queue can be resolved interactively to inspect its empty state.
## Deterministic browser journeys
Each journey creates a company, agent, and custom MCP connection in an isolated
embedded database. Ask first is configured through the permissions UI. The provider
returns fixture page names; **these are not real Notion pages**.
| Journey | Observed provider calls | Verified outcome |
| --- | ---: | --- |
| Approve in task | 1 | Stored call executes; resumed task posts Roadmap/Meeting notes; Connections pending item clears |
| Decline in Connections | 0 | One-click decline; open task updates; resumed agent reports decline |
| Always allow | 2 | Initial approved call and a later call with changed arguments; later task has no review |
| Provider failure | 1 | Human approval remains recorded; task shows execution failure and resumed agent reports it |
| Restart while waiting | 1 | Pending request survives actual server restart; approval executes once and task returns page results |
All journeys also exercise takeover dismissal/reopening, an ordinary comment while
pending, reload, and cross-tab synchronization. Review creation performs zero
provider calls. Traces and screenshots accompany each case.
[Open the evidence gallery](http://127.0.0.1:6020/) or the
[Playwright report](http://127.0.0.1:6020/report/). The final run passed all five
journeys in 1.4 minutes and released its port after teardown.
The local evidence directory is `.paperclip-runtime/reviews-evidence/`. It contains
the Playwright report, traces/screenshots, focused/full-check logs, baseline logs,
and `final-journey-identifiers.json` with request, invocation, interaction, and run IDs
from the passing port-3226 run. The report's attachments also contain
company/task/agent IDs and provider counts for its own run.
## Automated checks
- 384 focused gateway, policy/service, native bridge, and card/queue tests pass.
- 177 additional interaction route/service and policy tests pass.
- 18 startup tests pass after updating their app mocks with recovery services.
- 19 runner-catalog tests pass; the opt-in suite defines 16 local cells.
- Added scope/repair regressions pass: another agent/project still asks, explicit
denial and changed definitions remain effective, concurrent approval executes
once, multiple outcomes share one durable wake, and interrupted execution is
never replayed.
- Repository `pnpm -r typecheck` and `pnpm build` pass.
- Runner harness TypeScript, token gates, migration safety, and Storybook build pass.
- `pnpm test:run` was run, but the repository-wide result is not green. Feature
failures found in the initial run were corrected and their suites rerun above.
Thirteen unrelated failures were reproduced at the same unchanged master commit:
two workspace-runtime tests, four workspace-repair/control tests, three runtime
exposure tests, two company-skill path tests, one instance-cleanup path test,
and one worktree-seed spawn test.
The initial general-server lane ended with 5,968 passed, 39 failed, and 31
skipped tests across 498 files. Twenty failures were feature changes corrected
and verified in focused reruns; six were transient file-resource/runtime-port
failures that passed on rerun. The thirteen remaining failures reproduce on
master. The fail-fast runner did not reach later workspace/serialized lanes.
A complete green repository run is still required before PR-ready handoff.
## Live provider dependencies
The normal runner command was attempted with the opt-in flag and stopped with
`Missing runner E2E credentials: OPENAI_API_KEY, ANTHROPIC_API_KEY`.
The normal `test-drive --harness codex` command was also attempted from this
worktree. It bootstrapped a fresh isolated instance and reached startup recovery on
127.0.0.1:3105, then shut down with `No credential found. Set OPENAI_API_KEY`.
No Notion OAuth connection or real Notion page read was performed. The native Codex approval journey subsequently passed using existing local
ChatGPT authentication, as recorded below. Native ACPX Claude, legacy Codex CLI,
and legacy Claude CLI journeys remain unverified. The scripted process-adapter/browser evidence must not substitute for
those 16 acceptance cells or the four-profile real Notion exercise.
Provide the normal runner/test-drive credential setup and Notion account access
to complete those journeys. Secrets should remain in the normal local environment
or credential store, not in this report or chat.
## Final simplified UI verification
Before the master rebase, 155 component tests and all five browser journeys passed.
The final UI checks include the split approval menu, keyboard selection of Always
allow, and one-click decline. Evidence is in `.paperclip-runtime/reviews-evidence/minimal/`.
The browser run took 2.7 minutes; its restarted server required explicit process
cleanup after the tests completed. Live provider and model-runner dependencies
remain separate from this deterministic evidence.
## Native Codex approval and continuation
A real native Paperclip Runner agent used `gpt-5.6-sol` with existing local
ChatGPT authentication. Its initial run discovered the installed MCP fixture
action, called it with `query: "10 most recent pages"`, and yielded to a pending
server-owned review. The operator approved in the browser. The server executed
the stored request and delivered its result to a new native run.
- Source run: `106278a4-5411-41ba-b2a4-c150cdf7760d`.
- Action request: `7da846da-496f-4e6a-a151-0252e10f989c`.
- Continuation run: `18e11fe1-bd6a-4034-996e-e635d6cd57a6`.
- Final task status: `done`.
- Agent response: “The most recent fixture pages are **Roadmap**, **Meeting notes**,
and **Product research**.”
The success card keeps the raw tool result collapsed. Expanding it displays
formatted JSON. The latest five-journey browser suite passed in 1.7 minutes and
checks separate source/reply run IDs, readable output, and result expansion.
This is real Codex execution against a local fixture, not live Notion evidence.

View File

@ -0,0 +1,134 @@
# Connection reviews in task history
An agent call governed by **Ask human / Ask first** creates a server-owned
`request_confirmation.payload.toolAction` interaction linked to the existing
`tool_action_requests` record. No provider call runs while this review is pending.
The default approval lifetime remains one hour.
The task feed keeps one record for each review. **Review request** opens the
composer takeover. Dismissing the takeover only hides it; the review remains
pending, ordinary comments do not supersede it, and the agent is not resumed.
Multiple reviews retain separate records and the existing takeover navigation.
The card shows the app icon and a short request description. Destructive actions
retain destructive approval styling. Audit metadata and signed arguments remain
on the underlying review record.
**Approve & run** executes the signed, stored arguments once. **Decline** executes
nothing in one click. Both the task and Connections queue use
one decision transaction; a decision removes the pending queue item while its
history remains on the task. The human decision, resolver, remembered permission,
and execution outcome remain separate. Provider failure does not turn an approved
decision into a decline. Successful results stay collapsed behind the status chevron;
expanding it shows formatted JSON (or plain text). The resumed agent processes the
recorded result in a new turn and writes the user-facing answer. Live activity
invalidation refreshes both surfaces, with existing polling/reconnect reconciliation
retained.
## Remembered permission
Choose **Always allow** from the split button beside **Approve & run**. It
atomically saves approval and an action-wide trust rule for the
same agent, connection, and action, restricted to the originating project when one
exists. Future argument values may differ. The menu item exposes the scope through
its tooltip and accessible description; the receipt records the saved permission. If saving the rule
fails, the approval transaction rolls back and no provider call runs.
The rule remains bound to the reviewed catalog definition/schema. Changed
definitions require review again. Revocation, explicit denial, connection access,
and formal approval requirements remain effective. Manage/revoke rules through the
existing Connections trust-rule controls.
The accept/approve endpoints support optional `rememberAction: true`; omission
continues to approve once. Trust-rule promotion supports `argumentMode: "action"`;
its existing omitted/`"exact"` mode continues to bind exact argument values.
## Governed waiting and recovery
The gateway returns `approval_required` with the linked request/interaction IDs
and instructions to finish unrelated work, then yield `in_review` without retrying
or claiming completion. Agent task completion is rejected while a linked action
is pending, approved, or executing. Provider execution is server-owned.
`tool_action_deliveries` is a durable, content-free outbox keyed by action request.
It refers to the authoritative request, invocation, and interaction instead of
copying provider data. Once the originating runs have ended and no other task
interactions remain pending, ready outcomes are batched into one continuation
wake. The wake includes the recorded result/decline and instructions not to repeat
the operation. Native runners materialize validated server-owned interaction
outcomes; legacy runners receive the wake context and agent message. Existing
scheduler eligibility and budget gates still apply. Closed tasks retire receipts;
reassignment does not deliver the old agent's outcome to another agent.
Startup and periodic sweeps recover committed approvals, undelivered outcomes,
expiry, and incomplete feed projections. An execution left in progress for ten
minutes is marked failed with `tool_execution_outcome_unknown`. Its external
outcome is uncertain: inspect the provider before retrying. It is never
automatically replayed. This grace period exceeds the current approved-call timeout.
Migration 0249 adds the outbox and a partial unique wake-idempotency index.
The index is built transactionally; migration can briefly block wake-table writes
while PostgreSQL scans an existing large table. No external payload is added to the
outbox.
## Verification workflows
Run the credential-free, isolated browser suite:
```sh
PAPERCLIP_E2E_PORT=3222 pnpm exec playwright test -c tests/e2e/connection-reviews.config.ts
```
This starts a dedicated embedded database/server and local MCP fixture, configures
Ask first through the UI, and verifies approve, decline, remembered permission with
changed arguments, dismissal/reopening, ordinary comments, cross-tab queue/task
updates, provider failure, and restart while waiting. Assertions include useful
agent results and provider invocation counts. Screenshots, JSON journey identifiers,
and traces are attached to the Playwright HTML report. The deterministic agent is a
scripted process adapter; these results do not prove model-runner behavior.
Run the opt-in, local model-runner matrix with the harness's normal credentials:
```sh
PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS=1 pnpm test:e2e:runner -- --suite connection-reviews
```
The 16 cells cover approve, decline, always allow, and restart/resume for native
Codex, native ACPX Claude, legacy Codex CLI, and legacy Claude CLI. Qualified model
settings come from the existing runner catalog. This flag does not expand the
normal hosted/Daytona matrix. The fixture MCP provider is real HTTP but is not
Notion; live Notion evidence must be reported separately.
For a real Notion test, start `paperclipai test-drive` from this checkout using
valid provider credentials, verify its process checkout/port ownership, connect
Notion normally, set an available read-only search/list action to Ask human, and
perform the same approve/decline/always-allow journeys for all four profiles.
Capture request/run IDs, screenshots, traces, and actual page results. Missing
credentials or account/provider access are untested dependencies, never a pass.
## Storybook
```sh
pnpm --filter @paperclipai/ui exec storybook dev -p 6018 -c storybook/.storybook --no-open --ci
```
Open **Chat & Comments / Connection Reviews**. The production task thread/card and
Connections queue cover pending, dismissed/reopened, multiple requests, each
submitting action, recoverable errors, concurrent resolution, approved/executing,
success/failure, decline with/without a reason, expiry/cancellation, remembered
scope/receipt, approval options, narrow layout, and queue/empty states. The global
theme toolbar switches light/dark. Story actions simulate server responses; use the
browser suite for integration proof.
Provider output, execution errors, and review notes travel in the continuation's
`untrustedToolResults` field, separate from its control instructions. Both native
and legacy wake prompts render those fields as fenced JSON with an explicit
untrusted-data boundary. Embedded provider instructions cannot grant permission
or change the task's continuation policy. Wake materialization redacts secrets
and bounds each text field before rendering.
A continuation includes at most eight shortened result records and caps the
serialized wake context at 32 KB. It links to the task interaction API for all
full outcomes and instructs the agent to retrieve omitted or incomplete results
before finishing. A committed receipt cutoff preserves acknowledgement of that
referenced set across restart, without putting an unbounded ID list in the wake.
Task review queries reconcile every 20 seconds if a live event is missed.

View File

@ -6,6 +6,8 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr
## Counts
Execution recovery reuses the existing transcript header and task status. Routine phases add no list badges, status cards, or reconciliation dialogs. Only a transient reconnection changes the header text. Automatic recovery decisions remain in the local run log. Storybook **Tasks / Execution recovery** demonstrates quiet task lists, native and legacy transcript headers, and dashboard composition.
| Area | Count |
|---|---:|
| Shared primitives (`ui/src/components/ui/`) | 24 |
@ -404,3 +406,16 @@ Per-component rationale:
### 7.3 Interactive-card affordance (Run 3 review feedback)
`Card` gained an `interactive` prop — pointer cursor, quiet hover (border→foreground/20 + shadow-md lift), focus-visible ring — used when the whole card is a click target (e.g. Companies selector). Skills tiles (CompanySkills `SkillCard`) and artifact cards (`ArtifactCard`/`ArtifactGroupCard`) apply the same recipe verbatim since they cannot render through Card (button/Link semantics). Static container Cards stay affordance-free by design.
## In-task connections — 2026-09-07
| Reusable surface | Production owner | Hosts / coverage |
|---|---|---|
| Connection request card | `ui/src/features/connections/ConnectionIntentInteractionBody.tsx` | Task timeline, interaction card, design guide; pending, reuse, authorizing, retry, resolved, audience and error stories |
| Connection setup flow | `ui/src/features/connections/ConnectionSetupFlow.tsx` | Connections page and task dialog share provider forms, OAuth, validation and additive installs |
| OAuth handoff | `OAuthConnectStateScreen` in the shared setup module | Entry, starting, open window, blocked popup, closure, callback failure, retry and new-tab fallback |
| Identity and agent access | `AccessStep` in the shared setup module | Personal, organization, dedicated agent, unavailable identity and loading; task host fixes install reach to the requester |
| Setup completion | `ConnectionSetupCompletionScreen` in the shared setup module | Page and dialog; identity, granted agent access and enabled actions |
Independently addressable examples live under `Connections/In-task connections` in Storybook. The task composer remains available while a card is pending. These components use the existing token and primitive layers.

View File

@ -12,22 +12,86 @@ Delegated work and interactions persist their originating context. Retries retai
## Managed GitHub operations
New executions receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that runs capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
Executions with managed GitHub configured receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that runs capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
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.
Remote launchers prepend their directory to the execution target's effective
`PATH`. An explicit remote `PATH` override is preserved; otherwise Paperclip
reads the provider's environment before staging the launcher shell files.
This keeps legacy NVM and user-local agent installations available alongside
newer images with system-wide CLIs. The generated shell files retain that
combined path with managed `git` and `gh` first. Sandbox command checks use
the same sanitized environment as execution, so a CLI visible only in the
provider's default environment cannot pass the launch check. Failed path
discovery stops startup instead of silently falling back to a minimal path.
Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract.
## Legacy hosts and networking
When no managed GitHub connection is installed for an agent, standard-trust
local and SSH executions retain that execution host's existing Git and GitHub
CLI credentials, configuration, credential helpers, and SSH agent. Paperclip
does not import controller credentials into an SSH target. Sandbox, plugin,
and low-trust executions do not receive this compatibility fallback. Once a
managed connection is configured, unavailable or revoked access never falls
back to host authentication. Switching modes replaces the provider process
while preserving the settled conversation.
Runner network access is independent of GitHub credentials. The controller
enables networking for standard-trust execution. Low-trust runs and runners
without a controller network decision retain a restricted default. An operator
can set `PAPERCLIP_RUNNER_NETWORK_ACCESS=disabled` to restrict normal execution;
user environment bindings cannot override that decision. Outer execution-
environment network restrictions still apply. The controller projects the assigned worktree's Git metadata paths so
Git can operate without exposing unrelated workspace or provider state. The
sandbox also receives read access to validated provider executable resources
and the target host's DNS and CA files, including resolver symlink targets
outside `/etc`. Provider credential directories remain isolated.
A managed broker outage does not prevent local Git operations. Launchers clear
credentials and run the command without authentication, with a redacted error
category identifying configuration setup, transport, or capability rejection.
They do not retain a previous operation's token or replay a GitHub operation.
Healthy eligible grants for the same stable GitHub account ID take precedence
over duplicates with failed health checks. Credential acquisition can retry
once against another grant for that same principal and account, before any
GitHub operation begins. Run identity diagnostics include the selected
connection and grant IDs, without credential values. Access-refresh conflicts
retry once against current state and never turn a concurrency conflict into
a reconnect requirement.
## Dedicated accounts and diagnostics
An explicit dedicated-agent grant overrides personal selection. Revoked, disabled, unavailable, or ambiguous dedicated grants do not fall back to a person's account. Removing the dedicated configuration restores personal selection.
Connection setup and permissions display: “This agent uses this GitHub account for everyone's work, instead of the person giving instructions.”
The GitHub permissions page shows repositories across all connected accounts in one scrollable list. It has no account filter or repository search. Repository icons, private-repository indicators, refresh, and GitHub configuration links remain available. The “Add More Repos on GitHub” button opens GitHubs app installation and repository-access setup.
Multiple eligible connections for the same GitHub account are treated as one
identity, using GitHub's stable account ID rather than its login. The resolver
selects an available grant, preferring the newest authorization with a stable
ID tie-breaker. Duplicate eligibility includes an active credential record with
the correct owner, the OAuth access-token reference, and repository access
metadata. It keeps that grant's credential and connection policy together;
it does not combine repository access or bypass connection audiences. Distinct
accounts or unidentifiable duplicate grants remain ambiguous. Managed commands
print the redacted reason when GitHub access is unavailable, while unrelated
local operations can still proceed without credentials.
Run details show identity revisions and redacted GitHub results: responsible person, selected login when available, personal/dedicated source, and an unavailable reason. Tasks do not receive an additional identity indicator or takeover action.
## Deployment and verification

View File

@ -144,6 +144,16 @@ The active-lock lifecycle is part of the checkout contract:
Stale-lock recovery is crash recovery, not a retry loop. Paperclip must not clear or adopt locks held by non-terminal runs. After stale cleanup, a checkout `409` should mean a real live owner, status/assignee mismatch, unresolved blocker, or active gate still prevents checkout. Agents must treat that `409` as an ownership conflict and stop rather than retrying the same checkout.
### Known execution waits at admission
A known execution hold is a waiting condition, not a new execution attempt. Every issue wake must read the current effective reconciliation hold under the issue admission lock before creating a run. Resolved recovery bookkeeping can still carry a no-replay hold; only clearing the effective hold makes admission eligible again. The final dispatch gate remains required for changes after admission.
Repeated automatic signals for an unchanged gate share one durable skipped-wake diagnostic, scoped to company, agent, issue, gate code, and condition identity. The diagnostic retains the first request and counts later observations. This applies to execution reconciliation, dependencies, pause holds, company and agent availability, budget blocks, and disabled heartbeats. These diagnostics do not consume provider attempts and are never proof that a future wake was delivered. All current gates are checked again on the next wake, including the periodic dependency reconciliation sweep. Clearing one gate does not bypass another.
New comments received during an execution hold retain their individual deferred receipts and ordered comment ids. Release cannot drain those receipts while replay remains blocked; the next eligible wake can adopt them. Authorized external-chat requests also remain deferred with their exact durable receipt. They must use normal promotion and current authorization; a generic wake cannot adopt only their comment ids and discard their actor or session contract. A wait does not authorize replay, reset an incident retry budget, or bypass an interaction's delivery rules.
The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted.
### Pre-dispatch configuration validation
Pre-dispatch configuration validation is a distinct gate that runs after ownership and checkout are resolved but before the control plane actually dispatches a run.
@ -471,7 +481,7 @@ Agent-assigned `in_review` with no typed participant is only healthy when one of
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently.
When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. After a successful run with no review decision, Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. A failed participant instead follows the provider-continuity rules below: local conversational adapters can start a bounded continuation turn, while native sessions use validated resume/replacement. Other adapters retain their action-recovery gates. The original assignee stays unchanged. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently.
### Issue monitors
@ -552,6 +562,8 @@ Recovery rule:
This is an active-work continuity recovery.
After a productive successful run, recovery checks that the issue is still `in_progress` and assigned to the same agent under the enqueue transaction's issue lock. The sweep's earlier snapshot cannot authorize a continuation after completion, cancellation, reassignment, or a move to another status. A mismatch records a skipped wake receipt without creating a run. An empty queued continuation cancelled because the issue became terminal is omitted from task chat; its cancellation remains in the run log. Runs that actually started still show their stop state.
The same bounded rule applies when the previous heartbeat reported waiting on a local/background watcher and that watcher was killed, disappeared, or was never represented by a durable Paperclip primitive. Paperclip queues at most one continuation for the same recovery fingerprint. If the continuation also leaves only local watcher evidence, Paperclip must surface a real blocker or explicit recovery action instead of repeating continuation recovery. A new monitor, scheduled wake, healthy delegated blocker issue, or other durable source mutation resolves that recovery fingerprint normally.
#### Deliberate wait is not a lost run
@ -779,12 +791,98 @@ Auto-recovery is allowed when ownership is clear and the control plane only lost
Examples:
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled under the bounded conversation or provider-continuity rules below
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared under the bounded conversation or provider-continuity rules below
- assign an orphan blocker back to its creator when that blocker is already preventing other work
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
### Completion tools and final answers
A completion tool such as `paperclip_finish` reports task disposition; it does not
end the provider turn. Paperclip continues persisting and displaying provider
events until an authoritative turn terminal arrives. The completion report starts
no interruption timer. Existing execution timeouts, cancellation, governed waits,
and active-goal rules still apply. A later failed or cancelled terminal remains
failed or cancelled even when the agent already reported completed work.
The final assistant message is the visible task response. Response selection runs
after preceding event persistence completes; the completion summary cannot replace
an available final answer. Existing fallback and explicit-comment precedence still
apply. Stream closure without a turn terminal is not proof of success. Event
replay uses the existing source receipts and never repeats provider work merely
to recover recorded output.
### Provider continuity and bounded finalization
A permanently unusable native runner session may be replaced only with evidence that its predecessor is stopped and fenced, completed results and workspace state are preserved, required task history is available, and pending effects have been reconciled. A provider-native shell command or external write without a reliable outcome receipt is unknown. Unknown effects, integrity failures, and unverified process ownership never authorize speculative replay. Once automatic recovery is ruled out, Paperclip selects a conservative default: preserve recorded work, stop the affected task, and retain a durable no-replay hold. Unknown action outcomes remain unknown. No reconciliation form or user diagnosis is required.
Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. Automatic attempts retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates.
Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade.
Every continuation carries the triggering request, ordered user direction, interaction outcomes, completed work, and explicit history coverage. A delivered message remains part of the task's request after its connection or approval resolves. The original title is background; a completed Notion read does not satisfy a later Gmail request. Author and source-trust boundaries survive rendering into both native and legacy prompts. Missing required history must be fetched before dispatch rather than described as complete.
### Interrupted conversation continuation
An interrupted conversation does not permanently block its task. For local conversational adapters, Paperclip starts a new bounded turn with the existing session when compatible, or the full task conversation when the session is unavailable. The prompt says: “Your previous run was interrupted. Continue from where you left off.” The agent decides what remains from the history and latest user request. Paperclip never automatically replays recorded tool calls. Unknown past action outcomes are not a task-wide execution gate, and no action-reconciliation questionnaire is required.
Shutdown, process loss, and provider failure use the existing durable failure retry counter and delay. Ordinary failure recovery permits at most two automatic retries in a failure chain. Accepted-interaction infrastructure recovery retains its existing bounded policy. Repeated scheduler visits reuse the same successor; restarting the server does not reset the counter. After exhaustion, automatic attempts stop. A new explicit user message can start a fresh run and failure budget. Productive max-turn continuation and confirmed workspace waits keep their separate existing semantics.
Real gates still apply: company and task ownership, active provider ownership, budget limits, agent availability, dependencies, pending approval/review paths, and explicit pause holds. Native runner reattachment and finalization retain their existing ownership protocol. Process, HTTP, and gateway adapters retain their recovery rules because invoking those adapters can itself repeat an external action rather than start a conversation turn.
An operator Stop still waits for local provider termination. Stop alone never promotes deferred comments or starts an automatic continuation. Once stopped, the next explicit wake adopts pending comment IDs in order through the existing queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
Historical legacy interruption holds for conversational adapters no longer block new messages or Resume. Classification uses the runs saved adapter invocation or continuation policy, never the agents current adapter settings. Missing historical adapter evidence retains the hold. A terminal row with a live predecessor process or unreleased environment lease still blocks actual admission and Resume. Retry scheduling can happen before cleanup, but grants no execution authority. Recovery folds their obsolete no-replay bookkeeping without changing task ownership, status, or automatically waking old work. The audit trail remains readable. Native integrity and ownership holds, and non-conversational adapter holds, remain enforced.
The server projection remains available for diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. Recovery uses the existing transcript and run log rather than adding a reconciliation form. A cancelled run that never started says “Couldn't start” instead of implying that the agent answered.
### Codex startup and provider state
Paperclip trusts the server-selected startup execution root in the isolated
Codex configuration. Resolve that root on the execution host, including the
main repository trust key for Git worktrees. Start the provider in that same
root. This does not change sandbox permissions, tool authorization, secret
access, or Codex's separate per-hook trust policy.
Codex retains the model conversation. Paperclip resumes with `excludeTurns: true`,
reads lightweight thread state, and fetches paginated turn metadata or specific
turn items only when execution reconciliation needs them. Unsupported
or incomplete history is an explicit error, not evidence of idle execution.
The root-thread usage snapshot sent during resume belongs to its reported
completed turn. Retain a bounded local diagnostic and use cumulative totals as
a baseline; do not emit a warning or charge its historical `last` usage to the
new run. Preserve the baseline across recovery of the same run and start a new
delta when attaching a new run. Other stale-event and authority checks remain.
### Explicit user continuation after a native failure
An execution recovery hold blocks automatic replay. A new authenticated user
comment can authorize a fresh native conversation turn after the predecessor's
execution is confirmed stopped. This is a new request, not another automatic
attempt in the failed incident. The old attempt count and unknown action outcomes
remain unchanged.
Admission validates the persisted comment's author, task, and time against every
held predecessor. An agent-authored comment, an old queued request, or a generic
system wake cannot release a hold. The source task keeps its assignee. Process
ownership, active controllers, cleanup leases, pause, approval, budget, and normal
execution gates still apply. Dependency-blocked interaction mode remains limited
to its existing answer/triage contract.
The hold retirement, audit record, and new run commit together under the task
lock. The new turn uses a fresh provider session and retains the latest user
request, task history, completed work, and the interruption notice. It receives
no instruction to repeat old tool calls. Later messages cannot reset the old
incident's retry budget or create another automatic replacement for it.
The initial native admission path verifies local process identities. Missing
process identity or remote ownership without a target-aware stop proof remains a
hold; a terminal database status or a PID check on the wrong host is insufficient.
No historical task is automatically awakened by this change.
### Explicit Recovery Action
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.

View File

@ -214,6 +214,7 @@ task.run
│ ├── environment.startup
│ │ ├── environment.acquire
│ │ └── environment.workspace.realize
│ ├── skills.prepare
│ ├── heartbeat.prepare_before_environment
│ ├── heartbeat.prepare_after_environment
│ └── native.coordinator.claim
@ -827,16 +828,93 @@ To add a name or an enum value, extend the literal constant in
### Known behavior: aggregate retained body bytes
The HTTP/2 bridge bounds retained body bytes for one route only. Each route
holds up to 8,388,608 bytes (8 MiB) at its own peak (see
`HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`). The host
process admits up to 128 concurrent routes (see
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). The
process can therefore retain up to 1,073,741,824 bytes (1 GiB) of body data
across every route at the same time.
Each HTTP/2 bridge route holds up to 168,820,736 bytes (161 MiB) at its own
peak (see `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`).
The host process admits up to 128 concurrent routes (see
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). Those
two figures alone would let the process retain up to 21,609,054,208 bytes
(about 20.1 GiB) of body data across every route at the same time.
The process does not reach that figure, on two levels.
`HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES` (`http2-bridge-server.ts`) enforces a
real, live ledger: 1,073,741,824 bytes (1 GiB) across every route, not merely
an accepted paper ceiling. Every HTTP/2 stream creates one `BridgeBodyReservation` owner over
its lifetime, and every source-level full-body buffer that stream retains —
its request-body chunk array, the concatenated request body, the
response-body chunk array, and the concatenated response body — reserves
against that one owner before it allocates. A reservation that would pass the
process total is denied before it copies anything, and the host answers 503
instead of accepting the body. The reservation stays live for the response
body until the HTTP/2 write actually finishes flowing to the peer or the
stream closes, not merely until the write call returns, so a slow or
backpressured peer cannot hold response bytes in memory the ledger no longer
counts.
`HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES` adds a second, per-route ledger on top of
that process-wide one: each route's own reservations also check a ceiling
scoped to that one route (its own 168,820,736-byte peak from above), so one
busy or malicious route can pass its own ceiling and get denied with a 503,
but it can never spend the whole process-wide total and deny every sibling
route admission. This accounting covers source-level full-body buffers only:
internal Node.js and Undici copies (socket buffers, HTTP/2 frame buffers,
decompression buffers) stay outside it.
The generated gateway process inside the sandbox (`getSandboxCallbackBridgeServerSource`
in `sandbox-callback-bridge.ts`) enforces its own separate ledger, independent
of the two host-side ledgers above: each side bounds only the memory in its
own process. `readBodyBytes` reserves a request body's chunk bytes as they
arrive, then reserves the concatenated buffer's own byte count before
`Buffer.concat` allocates it, against a ceiling of `maxBodyBytes * 8` (4
concurrent bodies, each counted twice for its two live copies). A denied
reservation answers 503 with no forward call. Each request handler releases
its own reservation once the whole request settles: a completed response, a
thrown error, a client abort, or a deadline timeout all reach the same
release call.
This is accepted, known behavior. The process tracks no aggregate byte
ledger across routes: a per-route bound stops one busy route from starving
another route's own budget, but the host enforces no smaller ceiling on the
sum across every route.
Keep every dimension low-cardinality and free of user content.
### Shared skill preparation
`skills.prepare` measures the shared inventory listing and runtime materialization
inside `task.prepare`. It is also contained in the broader
`heartbeat.prepare_before_environment` interval; do not add those two durations.
Preparation failures emit a failed span even when no native session starts.
It carries no skill contents, identifiers, locations, or credentials. It uses the
existing run performance events and operator-configured OpenTelemetry endpoint;
no first-party Telemetry event is added.
Runtime preparation refreshes the company inventory once per listing. Local and
catalog directories remain direct sources, so edits are visible on the next
preparation. Explicit version selections still use their stored snapshots.
Reconstructed skills use `__runtime_cache_v1__/<skill-id>/<fingerprint>/files`
beneath company skill storage, with a sibling manifest of paths, sizes, and SHA-256
content digests. Every warm hit validates the manifest and exact file contents;
it does not fetch upstream, rewrite files, or remove directories. The fingerprint
includes installed source identity, revision, file inventory, and stored Markdown,
and excludes display names, stars, and general update timestamps. Manifests stay
outside the directory delivered to agents.
GitHub and skills.sh imports are cached only when pinned to a full commit SHA.
Remote freshness is explicit: update or reimport selects a new revision, including
supporting-file-only changes. A branch advancing upstream does not change an
installed revision. Legacy mutable refs retain uncached behavior until updated.
URL-only skills use stored Markdown. An unavailable new revision reports missing;
it never silently reuses an older revision. Stored `SKILL.md` remains a fallback,
but missing supporting files prevent publication of a reusable partial cache.
Builds publish read-only files and directories from unique staging directories.
A skill-scoped lock serializes builds and cleanup across processes. Cold builders
recheck that the skill still exists under its original key before reading files
and before atomic publication. Existing valid
revisions stay readable during updates. Invalid entries are quarantined in the
same skill cache root for inspection; rename/removal cleans up that skill's cache.
Read-only listings validate caches without downloading or repairing them. A
publication lock left by an abruptly terminated process is reported for operator
cleanup; remove it only after confirming its recorded PID is no longer running.
Run `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-skill-preparation.ts` for an isolated embedded
PostgreSQL benchmark with 114 mixed skills and at least 400 remote files. It
reports one cold sample and ten warm samples (one in a new process), refresh and
fetch counts, rebuilds, missing entries, and content checks. Upstream responses are
deterministic fixtures; use real deployed run spans for user-facing latency.

View File

@ -0,0 +1,87 @@
# Reliable execution and continuation — implementation and verification
## Outcome
Failed provider sessions retain their structured failure meaning. Recovery uses a shared incident budget of three provider attempts, including the original attempt. A fresh replacement requires a fenced predecessor, durable completed results, preserved workspace state, authorized task history, and reconciled pending effects. Unknown external effects receive an automatic preserve-without-replay disposition; they never require a reconciliation form.
The server owns the continuation envelope. It includes the triggering request, subsequent user direction, interaction outcomes, completed work, and an explicit history cursor. The latest request supplies the completion objective; an old task title cannot satisfy a new follow-up.
Confirmed provider descendant IDs survive restart and stay exact within a 4,096-entry inventory, with IDs bounded to 240 bytes. Capacity exhaustion stops provider work with an explicit reconciliation reason; it never evicts identities or pretends that a valid child is an integrity violation. Repeated progress diagnostics remain bounded.
Local CLI run-authored comments retain their provenance in history and cannot replace the latest human objective. Scheduled replacements and the final dispatch gate reject another run's execution or checkout lock.
A shared execution projection distinguishes confirmed work, recovery, scheduled retries, finalization, and real interaction waits. The composer remains usable. The failed predecessor remains inspectable after a replacement.
## Reproduced failure
A Codex notification named another thread. Recovery attempted an unusable checkpoint. On a later attempt, the admission transaction held the task row while waiting for provider spawn or adapter settlement. Failure finalization waited for the same row. PostgreSQL confirmed the blocking transaction. The final gate now initiates the adapter handoff while ownership is locked, then commits without awaiting provider work. Bootstrap and finalization can acquire the same rows independently; a competing owner cannot enter between the final check and handoff.
A connection continuation also omitted the follow-up that requested a second service. Its old completion objective referred to the first service. The continuation envelope now preserves the source request even when the preceding run already received that message.
The shared protocol-integrity and cleanup changes incorporate the relevant prerequisites from PR #13038. They do not require its chat feature.
## Functional evidence before the quiet UI revision
Five independent fresh source-CLI `test-drive` instances passed these browser journeys:
| Journey | Provider | Result |
| --- | --- | --- |
| Safe replacement | Native Codex driver with deterministic model and MCP fixtures | A second-service request survives the injected failure and receives a tool-backed answer without another Run click. |
| Unknown action | Native Codex driver with deterministic fixtures | No speculative replay. The operator records action outcomes before continuation. |
| Restart during retry | Native Codex driver with deterministic fixtures | Durable retry survives server restart with one successor. |
| CEO descendant events | Native Codex driver with deterministic fixtures | Provider-confirmed descendant notifications do not crash or complete the root. |
| Unsupported legacy recovery | Deterministic process adapter | Unknown action outcomes create an operator-owned recovery action. |
These fixtures do not prove live provider authentication. A separate retained live instance completed the current Gmail request with native Codex and model `gpt-5.6-sol`: one search call and five thread reads. History, assignment, and existing connections were retained. No mail was sent. Private provider history, instance identifiers, and credentials are excluded from this repository.
The live journey required explicit operator reconciliation during diagnosis. It proves the repaired functional path, not a frictionless first attempt.
## Automated checks before the quiet UI revision
Before PR rebase, the repository test groups, typecheck, build, token gates, and Storybook build passed. The server test groups ran in shards, with affected suites rerun after repairs. Runner TypeScript passed 1,665 tests with eight skips; Node contracts passed 38 tests; the full Rust workspace passed. After the rebase, the PR checks verify the new head, including the session-goal changes on master.
Focused coverage includes provider event identity, structured failure propagation, atomic finalization, cleanup failure, lease loss, publication recovery, one-successor dispatch, shared retry budgets, quota monitors, current reviewer authorization, ownership changes, continuation context, and uncertain actions. Legacy adapters need positive pre-provider evidence to authorize a bootstrap retry. A pre-provider workspace wait does not consume the failure budget.
## Browser and Storybook reproduction
Finish the runner build before browser acceptance. Do not rebuild generated provider artifacts while a fixture consumes them.
```sh
pnpm exec playwright test --config tests/e2e/execution-recovery/playwright.config.ts recovery.spec.ts
pnpm --filter @paperclipai/ui build-storybook
RECOVERY_STORYBOOK_URL=http://127.0.0.1:6108 pnpm exec playwright test --config tests/e2e/execution-recovery/playwright.config.ts storybook.spec.ts
```
Serve the built Storybook at the configured URL before the second browser command. The recovery suite creates and stops fresh test-drive instances itself. Each journey records its actual URL, data directory, checkout, task/run identifiers, provider calls, and screenshots in the Playwright output directory.
The quiet UI revision removes the execution status card, list badges, and reconciliation dialog. Existing transcript headers may briefly show Reconnecting. The source task and composer remain visible. Unknown action outcomes receive a durable automatic no-replay disposition; no operator questionnaire is shown.
Independently addressable stories under `tasks-execution-recovery`:
`working`, `reconnecting`, `retry-scheduled`, `waiting-for-workspace`, `finalizing`, `safely-replaced`, `recovery-exhausted`, `uncertain-action`, `unavailable-recovery`, `waiting-for-access`, `waiting-for-answer`, `narrow-long-error`, `composer-during-recovery`, `task-list-badges`, `task-list-badges-canonical`, `native-chat-status-labels`, `legacy-chat-status-labels`, `dashboard-status-labels`.
Open a story with `?path=/story/tasks-execution-recovery--<suffix>`. The badge story names remain stable for review links; their rows now demonstrate the absence of execution badges. Earlier screenshots of the status card and dialog are obsolete and are not acceptance evidence for this revision.
Representative screenshots for the quiet presentation:
- [Task lists without execution badges](../assets/execution-recovery/quiet-task-list.png)
- [Temporary reconnection in the existing transcript header](../assets/execution-recovery/quiet-retry.png)
- [Fixture continuation after refresh](../assets/execution-recovery/fixture-completed.png)
## Quiet recovery revision verification
All five fresh deterministic journeys passed. The uncertain-action and legacy journeys were then rerun with assertions for the visible blocked status, absence of the reconciliation form and Retry control, and preserved draft text. Both passed. A missing execution projection in the history response was fixed so the feed does not offer a retry that the server will reject.
The 72 Storybook theme/viewport combinations passed, with 16 affected combinations rerun after presentation changes. Repository typecheck, build, token gates, and Storybook build passed. Focused recovery, projection, activity-history, and UI tests passed. The full local suite exposed a fixture race that closed a task before provider acceptance; the fixture now waits for actual provider acceptance, and all 64 tests across the affected continuation and recovery-route suites pass.
Recovery decisions remain visible in local run logs. The durable status broadcast contains only run/agent identifiers, status, timestamps, and delivery ID; it does not broadcast provider output or errors. New verified evidence can clear an automatically settled hold through the existing authorized evidence API. Generic retries and duplicate requests cannot clear the hold. No dialog is exposed for this path.
## Rollout and limits
The recovery migrations were renumbered to 02500254 after master added session goals and action-delivery storage. Their SQL remains idempotent for instances that applied the earlier branch numbers. Migration snapshots include both sets of schema changes.
Control transitions have a 60-second deadline and a 15-second reconciliation cadence. With a healthy database and scheduler, abandoned transitions must be repaired or surfaced within 90 seconds. Healthy provider silence has no new timeout. An upgrade never automatically replays ambiguous historical work. Default CEO instructions remain unchanged.
Fresh test-drive instances use local-trusted mode. Authenticated/cloud browser behavior, every legacy provider, and every deployment topology were not exercised live. Automated tests cover authorization and company boundaries. An early terminal-delivery screenshot still showed Working briefly; the final refreshed screenshot above shows the completed state.
The final ownership-handoff regressions passed (42 tests across the dispatch adapter and stale-queue suites). They verify competing ownership at the handoff boundary and provider failure before a spawn callback, without retaining database locks for provider completion.

View File

@ -0,0 +1,50 @@
# Experimental chat channel landing plan
The chat integration lands in two dependent changes after the native runner
prerequisites in #13092. The runner change is not one of these two chat changes.
## First change: provider and data foundation
Add the closed channel types, additive database schema, pinned provider adapter
patches, packaged dependencies, and opt-in runtime and transport helpers. Include
their real-parser, synthetic-transport, migration, and package qualification tests.
Keep the existing server routes, application catalog, experimental settings,
heartbeat dispatch, and Board entry points unchanged. This change does not start
provider connections or expose partially implemented channel routes. The existing
production GitHub tool connection keeps its current path.
Validate this change against the existing master consumers independently. Run
repository types, tests, and build, the historical database upgrade checks, and
the release patch-packaging checks. Exact-head CI and code review are required.
The foundation exports the connection-purpose type needed by the schema, but
does not add the channel transport or application kind to existing consumers.
Durable command-registration and Teams transfer stores remain in the second
change alongside their service authorization. Tenant foreign keys bind task,
agent, comment, delivery, and action references to their company. Nullable
deletion uses an ID-only `SET NULL` action plus a tenant `NO ACTION` constraint,
so deleting a parent cannot clear the required company ID. Action references to
conversation and principal retain history instead of silently detaching it.
These corrections use forward migrations; historical SQL remains unchanged.
The runtime registry retires the prior endpoint owner before exposing a
replacement, and fences initialization against removal or shutdown. It does not
initialize providers itself: the service caller must install current guarded
callback context before starting a Gateway connection.
## Second change: gated service and Board integration
Add company-scoped durable admission, identity and reach authorization, leases,
queues, publications, native controls, and the Board user journey. This includes
the provider-specific registration and service wiring for Slack, Discord,
Telegram, Teams, and GitHub. Keep activation behind the experimental channel
setting. A capability is not permission to bypass current source or user checks.
Run joined service and recovery tests, all repository checks, and the deterministic
browser suite on the composed head. Live provider observations supplement these
checks; mocked transport and browser fixtures are not live-provider proof.
Merge the foundation first. Then update the integration onto current master and
repeat exact-head verification and review. Each chat pull request must remain
under 500 changed files. Preserve uncertain delivery outcomes and explicit
unsupported cases rather than claiming complete provider qualification.

View File

@ -0,0 +1,93 @@
# Codex integration acceptance — 2026-09-09
## Environment and scope
All live tests used the pinned Codex CLI 0.153.4 and gpt-5.6-sol.
Each fixture used an isolated Codex home. A repository contained known launch
notes, a configuration marker, a skill marker, and a harmless SessionStart hook.
The hook only appended a line to a fixture file.
Streaming and feed-display changes remain separate pull requests. These tests
used their combined implementation checkout. Each PR is also checked on the
current master before merge.
## Native browser acceptance
A fresh test-drive instance started without tasks or prior runs. The test agent
used paperclip_runner with Codex. Browser actions created a task in the fixture
project, requested the notes, and sent two follow-up messages. The test server
was restarted between the first answer and the follow-ups.
All three native runs succeeded. They returned the expected notes and markers,
then answered a date-change question and recalled the original reference.
Answers persisted after refresh. No provider notice appeared. The task composer
remained usable.
The task policy opened new provider threads for completed-task follow-ups.
This browser test does not prove same-thread resume. The next tests do.
## Same-thread local driver acceptance
The production TypeScript driver ran an initial repository read, a follow-up,
a provider shutdown, persisted-session recovery, and a second follow-up.
All answers used the same provider thread and retained the required context.
- No provider notices appeared.
- Configuration and skill markers loaded.
- The approved hook ran once at startup and once at cold resume.
- Run usage was 40,445 + 13,624 + 10,538 = 64,607 tokens.
- The sum equaled the final cumulative session usage. Historical usage was
not charged again.
- Resume usage produced only the bounded local diagnostic.
## Daytona driver acceptance
A disposable Daytona sandbox ran the production TypeScript driver bundle.
The test installed Codex 0.153.4 because the image had an older version.
Startup, warm follow-up, process shutdown, cold resume, and the second
follow-up all succeeded on the same provider thread.
- Configuration and skill markers loaded.
- The approved hook ran once at startup and once at cold resume.
- No trust, history-deprecation, or settled-turn usage warning appeared.
- Run usage was 23,006 + 11,611 + 8,120 = 42,737 tokens.
- The sum equaled the final cumulative session usage.
- A separate warning about missing system bubblewrap remained visible.
Codex used its bundled copy. The change does not suppress that warning.
The sandbox was deleted after evidence collection. Remote Paperclip UI and
remote Rust execution were not tested.
## Hook trust and experience limits
Codex reviews hook hashes separately from repository trust. The test queried
hooks/list and approved only the harmless fixture hash through config/batchWrite.
Product code does not bypass this policy or copy operator configuration.
The task feed worked correctly. Two separate issues remain outside this change:
a dashboard preview could retain old running text, and test-drive restart could
use a different database when its saved port did not match its actual port.
The fixture configuration was corrected before sending further messages.
Existing test data was preserved. Transitions were sampled rather than filmed.
## Automated verification before PR preparation
- Codex/native-transport TypeScript: 333 passed.
- Adjacent OpenCode/ACPX driver, accounting, and recovery tests: 49 passed.
- Same-run attach and usage baseline regression: 4 passed.
- Rust library, serialized: 226 passed.
- Rust Codex integration: 72 passed, 1 ignored; two new pagination tests passed.
- Production native server integration passed after rebuilding its stale fake
provider. The test now asks Cargo to check binary freshness.
- Repository typecheck and build passed.
- Repository test groups have passing coverage after environment retests.
General-server coverage was 7,217 passed and 30 skipped. Route coverage was
2,175 passed and 4 skipped across 144 files.
The initial monolithic test command did not pass cleanly. Parallel test loads
caused timeouts; isolated reruns passed. CLI database tests reached the macOS
shared-memory limit; they passed after unused disposable fixture servers were
stopped. No production behavior or timeout setting changed for those failures.
The default parallel Rust library run also had Claude fixture transport failures;
the complete serialized rerun passed. PR checks record validation after replaying
these changes onto current master.

View File

@ -0,0 +1,87 @@
# Correct Codex startup trust, history reads, and resume notices
Date: 2026-09-09. Approved scope: three Codex integration fixes. This supersedes
this document's earlier exploratory recommendations. Feed display and
full-answer streaming are separate preceding changes.
## Product rules
1. Trust the execution root that Paperclip selects at startup. Resolve it on
the execution host, including a Git worktree's main repository trust key.
Write only the isolated Codex configuration. Keep sandbox, tool, and secret
controls authoritative. Later directory changes do not grant new trust.
2. Codex retains model conversation context. Paperclip reads provider state to
establish execution authority or recover specific evidence. Normal resume
must not download historical message contents.
3. A resume usage snapshot describes completed work. It can establish a
cumulative baseline, but must not charge that work to a new run or produce
a warning. Other stale notifications and invalid authoritative events keep
their existing validation.
## Implementation
### Resume usage
Handle the exact root-thread `thread/tokenUsage/updated` snapshot before the
settled-turn warning branch. Keep its reported historical turn identity and a
bounded local `codex_resume_usage_snapshot` diagnostic. The Rust normalizer
must not emit a billable usage event for the snapshot. The TypeScript driver
persists its cumulative baseline with the existing checkpoint and reports a
monotonic run delta. Attachment begins a new delta at the last observed total;
recovery of the same run preserves its baseline. Repeated snapshots are not
additional receipts. Missing thread or turn identity does not gain authority.
### Supported state and history reads
Use `excludeTurns: true` for resume and `includeTurns: false` for thread state.
Request turn metadata with `thread/turns/list` and `itemsView: notLoaded`.
Only request `thread/items/list` content for a specific turn when reconciliation
needs its final answer, tool result, or completion evidence. Follow cursors,
keep stable order, deduplicate IDs, reject repeated cursors and incomplete
responses. A missing API reports a compatibility/read error; there is no
Codex full-history fallback.
Rust uses lightweight idle/active state, then paginated metadata when it needs
an active turn identity. The controller's runner transport serves targeted
recovery evidence from committed runner events; it rejects content reads
outside the retained turn window. Existing non-Codex proxy behavior stays
separate from Codex's protocol requirements.
### Startup trust
Before spawning Codex, canonicalize the selected root and add its trusted
project entry to the isolated config. Preserve unrelated settings and use a
private atomic replacement. Start the provider process in that same root,
so startup cannot load the Paperclip server checkout by accident. Persist the
startup directory in the existing optional session checkpoint for cold resume.
Remote roots are resolved on the execution host.
Retain the server-selected permission profile on subsequent TypeScript turns,
including Daytona's existing external sandbox profile. Do not change approval
policy or bypass the external sandbox boundary.
Repository trust loads hook definitions, but Codex 0.153.4 separately reviews
individual hook hashes. Preserve that policy. Acceptance explicitly approves
only the harmless fixture hook through Codex's supported config API; product
code does not bypass hook trust or invoke provider hooks itself.
## Verification and exclusions
Use Codex CLI 0.153.4, the pinned supported baseline. Test snapshot replay and
cold recovery accounting, cross-thread isolation, full-history avoidance,
metadata/item pagination, incomplete evidence, worktree/non-Git/canonical
trust, malformed config, and unchanged sandbox profiles. Run focused Rust,
TypeScript, server lifecycle/accounting tests, then repository typecheck,
tests, and build.
Use fresh local test-drive data and a disposable Daytona sandbox with real
Codex. Verify an initial repository read, two follow-ups, cold resume, config
and skill markers, one hook execution per startup/resume, exact cumulative
usage arithmetic, and absence of the three original warnings. Inspect the
browser answer after refresh. Record local/native and remote/TypeScript
proof separately, including any environment warnings or unverified behavior.
No new UI, task state, public API, database migration, retry policy, or session
replacement workflow. Preserve the Gmail handoff patch and existing test data.
See [acceptance evidence](2026-09-09-codex-integration-acceptance.md).

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."
};
});

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