## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip Cloud instances can inject a trusted operator HTML snippet
before `</body>` in served UI pages (#13168)
> - Operators deliver env vars to managed instances through
hosting-provider APIs
> - Web application firewalls in front of those APIs reject request
bodies that contain raw `<script` markup
> - The snippet value always contains a script tag, so the firewall
blocks its delivery every time
> - This pull request adds `PAPERCLIP_CLOUD_UI_SNIPPET_B64`, which
carries the same snippet as standard base64
> - The benefit is that operators can ship the snippet through
WAF-fronted delivery pipelines without firewall exceptions
## Linked Issues or Issue Description
Refs #13168 (introduced the Cloud UI snippet).
**Subsystem affected**
Server UI shell serving: `server/src/cloud-ui-snippet.ts`, used by
`static-index-html.ts` and `app.ts`.
**Current behavior**
`PAPERCLIP_CLOUD_UI_SNIPPET` is the only way to configure the Cloud UI
snippet. The value is raw HTML. Delivery pipelines that write env vars
through provider APIs can fail to deliver it: web application firewalls
classify a request body that contains `<script` as an injection attempt
and block it. Verified against Railway's GraphQL API, which sits behind
Cloudflare: a variable value with a bare `<script></script>` returns
HTTP 403 before authentication, and JSON unicode escapes (`<script`) do
not bypass the block. The snippet is exactly the kind of value that
always contains a script tag, so such pipelines cannot deliver it at
all.
**Proposed behavior**
A new optional `PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet
as standard base64 of the UTF-8 HTML. The server decodes it and injects
the result through the existing path. The plain variable wins when both
are set. Whitespace and line wrapping in the value are tolerated. A
value that is not canonical base64, or that decodes to blank, is ignored
instead of injected as garbage.
**Reason and benefit**
Operators can deliver the snippet through WAF-fronted APIs without
requesting firewall exceptions. Base64 contains no markup, so the
firewall has nothing to match. Existing deployments see no behavior
change.
**Breaking changes**
None. The new variable is optional. The existing variable is unchanged
and takes precedence.
## What Changed
- `server/src/cloud-ui-snippet.ts`: resolve the snippet from
`PAPERCLIP_CLOUD_UI_SNIPPET` first, then from base64-decoded
`PAPERCLIP_CLOUD_UI_SNIPPET_B64`; ignore non-canonical or blank-decoding
values.
- `server/src/__tests__/cloud-ui-snippet.test.ts`: cover
decode-and-inject, plain-wins precedence, whitespace tolerance,
invalid/blank values, and the self-hosted no-op.
- `doc/cloud-ui-snippet.md`: document the variant, how to produce the
value, and the ignore rules.
## Verification
- `pnpm vitest run server/src/__tests__/cloud-ui-snippet.test.ts
server/src/__tests__/static-index-html.test.ts` — 13 tests pass.
- `tsc --noEmit` in `server/` passes.
- Manual check: on a Cloud-managed instance, set
`PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)"`, restart,
open `/`, and confirm the decoded snippet appears before `</body>`. On a
self-hosted instance, confirm no snippet is injected.
## Risks
Low risk. The injection path and the Cloud-managed gate are unchanged. A
malformed base64 value is ignored, so the failure mode is a missing
widget, not corrupted HTML. The decoded value is trusted operator
configuration, the same trust model as the plain variable.
## Model Used
- Claude Fable 5 (Anthropic, `claude-fable-5`) via Claude Code CLI,
agentic workflow with tool use (code edits, test runs, and live API
verification of the WAF behavior).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Adds an optional Cloud-only HTML snippet so operators can load Plain’s
standard chat bubble. **6 files, 16 implementation lines added; 102
additions including tests and docs.**
## Thinking Path
> - Paperclip serves Cloud and self-hosted users.
> - Closed beta users need a way to report problems.
> - Plain provides a ready-made chat widget.
> - Cloud operators can load it through a generic deployment setting.
> - Self-hosted instances ignore that setting.
## Linked Issues or Issue Description
**Subsystem affected**
Server-served UI HTML.
**Problem or motivation**
Enable a chat bubble in Cloud without adding a support feature to the
React app.
**Proposed solution**
Insert trusted `PAPERCLIP_CLOUD_UI_SNIPPET` HTML before `</body>` when
the existing Cloud-managed predicate is true. The setting is off by
default. Related Cloud-gated integration: #12190.
## What Changed
Review the [final
diff](https://github.com/paperclipai/paperclip/pull/13168/files) in this
order:
1. `server/src/cloud-ui-snippet.ts`: the eight-line Cloud gate and HTML
insertion.
2. `server/src/static-index-html.ts` and `server/src/app.ts`: apply it
to static root/index, SPA routes, and Vite HTML.
3. Two test files and `doc/cloud-ui-snippet.md`: boundary checks and
setup instructions.
React UI, customer identity, and database behavior are unchanged. The
existing feedback flag remains. Plain chat is anonymous; no Paperclip
name, email, or organization is supplied.
## Verification
- **Greptile: 5/5**, no actionable findings, reviewed commit
`04bb44515`.
- **[CI
passed](https://github.com/paperclipai/paperclip/actions/runs/34535763243)**,
including build, typecheck, server tests, and end-to-end tests.
- Local: six focused tests, full typecheck, and build passed. The full
local suite has not produced a final result; CI is the completed full
verification.
- Staging deployment and live chat testing remain to be done.
### Staging setup
Set **one server environment variable**, `PAPERCLIP_CLOUD_UI_SNIPPET`,
to:
```html
<script>
(function(d) {
var script = d.createElement('script');
script.src = 'https://chat.cdn-plain.com/index.js';
script.onload = function() {
Plain.init({ appId: 'liveChatApp_01M26J213F6RR53YRARZVAFCZZ' });
};
d.head.appendChild(script);
})(document);
</script>
```
This is the public staging app ID. **No API key or signing secret is
needed.** Deploy to staging and restart the app with this setting. Test
`/`, `/index.html`, and an organization dashboard; send a message and
confirm a support reply returns. Production rollout is separate.
[Plain embed docs](https://www.plain.com/docs/product/channels/chat) ·
[Configuration and
rollback](04bb445151/doc/cloud-ui-snippet.md)
## Risks
Only trusted operators should set this value. The HTML is public and
scripts execute in the app origin; do not include secrets or
user-provided HTML. Plain owns the anonymous browser session, with no
Paperclip account-switch integration. To roll back, unset the variable,
restart, and refresh open tabs.
## Model Used
OpenAI Codex (GPT-6), with repository inspection and code execution.
Exact runtime model identifier and context size are not exposed in this
session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>