File rebuild (round-trip)
CloakAPI can round-trip a whole document, not just text. You attach a file (see
File attachments — note the caution there: on the blind
relay, file extraction and tokenisation happen client-side via the browser extension or
desktop app, not on our servers), and once the model responds you can turn the (detokenised)
response back into a real file — a .docx, .pdf, .xlsx, and 13 other formats — via
POST /v1/reconstruct.
How it works
1. Client uploads a file (multipart or base64) alongside a chat/messages call │ ▼2. Gateway [FileIngestMiddleware] converts the file to Markdown via MarkItDown (raw bytes discarded — never stored, never forwarded) │ ▼3. PrivacyTokenisationMiddleware replaces PII in the Markdown with [TOKEN_*] placeholders before the request leaves the trust boundary │ ▼4. Upstream LLM provider sees only tokenised Markdown, returns a tokenised response │ ▼5. Caller detokenises the response client-side ("balanced" / "continuity" tiers) using the alias map it already holds for the conversation │ ▼6. Caller calls POST /v1/reconstruct with the detokenised text + a target format │ ▼7. Gateway shells out to the `cloak-reconstruct` CLI (packages/reconstruction), which regenerates a real file and returns it as a binary download with receipt headersAt the max privacy tier, detokenisation happens entirely on the desktop app —
the gateway never sees plaintext PII, so there is nothing for it to reconstruct;
the desktop app rebuilds the file locally instead. /v1/reconstruct is for the
balanced and continuity tiers, where the caller (portal, desktop app, or an
SDK-based integration) holds the alias map and asks the gateway to do the file
assembly work.
Request
POST /api/v1/reconstruct
This endpoint currently sits behind the same session gate as the rest of the
authenticated /v1/* surface — auth:sanctum, verified.json, and
legal.accepted (see apps/gateway/routes/api.php:692-999, group starting at
line 692; the route itself is registered at line 999). In practice today that
means it’s reachable from an authenticated portal session (browser cookie), not
yet from a bearer cloak_... API key — direct API-key support for this endpoint
is in active development.
{ "response_text": "Dear [TOKEN_PERSON_1], your invoice for [TOKEN_ORG_1] is attached.", "alias_map": { "conversationId": "conv_9f1c2b7a", "hmacPrefix": "a1b2", "entries": [ { "placeholder": "[TOKEN_PERSON_1]", "original": "Henry James" }, { "placeholder": "[TOKEN_ORG_1]", "original": "Acme Corp" } ] }, "target_format": "docx", "input_mime": "application/pdf", "fidelity": "structural"}| Field | Type | Required | Notes |
|---|---|---|---|
response_text | string | yes | The (already detokenised) text to rebuild. Max 10 MB (MAX_RESPONSE_TEXT_BYTES). |
alias_map.conversationId | string | yes | Max 256 chars. |
alias_map.hmacPrefix | string | yes | Max 16 chars — the request-scoped HMAC prefix salt CloakAPI’s tokeniser stamped on every placeholder it emitted for this conversation. |
alias_map.entries[] | array | yes | Each entry: { placeholder, original }, both strings, max 512 chars each. |
target_format | string | yes | One of the 16 supported output formats — see below. |
input_mime | string | no | Original file’s MIME type, e.g. application/pdf. Defaults to text/plain. |
fidelity | string | no | textual | structural | layout | typographic. Defaults to structural. |
Source: apps/gateway/app/Http/Controllers/Api/V1/ReconstructController.php:85-110.
Note that only placeholders whose prefix matches alias_map.hmacPrefix are honoured —
tokens from a different conversation, or text that merely looks like a placeholder,
are rejected by design (packages/reconstruction/src/alias-resolver.ts).
Supported formats
The gateway validates target_format against a fixed allowlist of 16 formats
(ReconstructController::ALLOWED_FORMATS), matching the 16 “implemented” formats in
the reconstruction package’s format matrix (packages/reconstruction/docs/matrix.md):
| Format | MIME type | Round-trip fidelity |
|---|---|---|
txt | text/plain | GREEN — trivial |
md | text/markdown | GREEN — pass-through |
html | text/html | GREEN |
docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document | YELLOW — style skeleton, new paragraphs get default style |
pdf | application/pdf | YELLOW — textual fidelity, no original glyph layout |
xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | GREEN — values only, formulas write-only |
csv | text/csv | GREEN |
pptx | application/vnd.openxmlformats-officedocument.presentationml.presentation | YELLOW — structured JSON or auto-split by headings |
epub | application/epub+zip | YELLOW |
latex | application/x-latex | GREEN — raw .tex, run pdflatex yourself |
svg | image/svg+xml | YELLOW — text-to-SVG, no original vector layout |
srt | application/x-subrip | GREEN |
vtt | text/vtt | GREEN |
json | application/json | GREEN — RFC 8785 canonical |
yaml | text/yaml | GREEN |
xml | application/xml | GREEN |
Not yet supported (requesting these as target_format fails Laravel validation with a
422 today — see Error codes): png/jpg image output, rtf, odt/ods/odp,
mmd (Mermaid), mp3, mp4, zip. See packages/reconstruction/docs/matrix.md for the
full deferred-formats rationale.
Response
On success, the gateway returns the reconstructed file as a binary download, not JSON:
HTTP/1.1 200 OKContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.documentContent-Disposition: attachment; filename="reconstructed.docx"Content-Length: 48213X-CloakAPI-Receipt-Input-Mime: application/pdfX-CloakAPI-Receipt-Output-Mime: application/vnd.openxmlformats-officedocument.wordprocessingml.documentX-CloakAPI-Receipt-Tokeniser-Version: <version>X-CloakAPI-Receipt-Reconstructor-Version: <version>X-CloakAPI-Receipt-Input-Hash: sha256:...X-CloakAPI-Receipt-Output-Hash: sha256:...X-CloakAPI-Receipt-Fidelity-Achieved: structuralX-CloakAPI-Receipt-Unresolved-Tokens: 0X-CloakAPI-Receipt-Partial: false
<binary file bytes>Header source: ReconstructController.php:224-237. X-CloakAPI-Receipt-Unresolved-Tokens
is a count, not a boolean — any value greater than zero means some placeholders in
response_text could not be resolved against alias_map (the file is still returned;
those spans are left as literal placeholder text).
file_rebuilt receipt claim (planned)
Today the receipt evidence for a reconstruction call is carried only in the
X-CloakAPI-Receipt-* response headers shown above. Folding this into a signed
claims.file_rebuilt block on the OpenReceipt envelope — mirroring the
claims.tokenisation claim already used for file attachments —
is in active development. The expected shape, once shipped, is:
{ "claims": { "file_rebuilt": { "target_format": "docx", "fidelity_requested": "structural", "fidelity_achieved": "structural", "input_mime": "application/pdf", "output_mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "input_hash": "sha256:...", "output_hash": "sha256:...", "unresolved_tokens": 0, "partial": false } }}Treat this block as forthcoming — verify against the response headers until it ships.
Limits
| Limit | Value | Source |
|---|---|---|
Max response_text size | 10 MB | ReconstructController::MAX_RESPONSE_TEXT_BYTES |
| CLI process timeout | 60 seconds (hard) | ReconstructController.php:145,184 |
alias_map.conversationId | max 256 chars | validation rule |
alias_map.hmacPrefix | max 16 chars | validation rule |
alias_map.entries[].placeholder / .original | max 512 chars each | validation rule |
curl example
curl -s https://api.cloakapi.io/api/v1/reconstruct \ -H "Content-Type: application/json" \ -H "Cookie: cloakapi_session=$SESSION_COOKIE" \ -H "X-XSRF-TOKEN: $XSRF_TOKEN" \ -d '{ "response_text": "Dear [TOKEN_PERSON_1], your invoice for [TOKEN_ORG_1] is attached.", "alias_map": { "conversationId": "conv_9f1c2b7a", "hmacPrefix": "a1b2", "entries": [ { "placeholder": "[TOKEN_PERSON_1]", "original": "Henry James" }, { "placeholder": "[TOKEN_ORG_1]", "original": "Acme Corp" } ] }, "target_format": "docx", "input_mime": "application/pdf", "fidelity": "structural" }' \ -o reconstructed.docx -D -(This endpoint is session-authenticated today — see Request — so a plain
Authorization: Bearer cloak_... header alone won’t yet authenticate against it; use the
portal’s session cookie + CSRF token as shown, or drive it from the portal/desktop UI
directly, described below.)
TS and Rust SDK snippets
The CloakAPI SDKs are not yet published (npm/crates.io distribution is pending) —
until they ship, call the HTTP endpoint directly as shown above. The snippets below are
a preview of the planned rebuildFile / rebuild_file helpers; the exact signature
may still shift slightly before release.
TypeScript (sdks/typescript, following the existing CloakClient request pattern in
sdks/typescript/src/client.ts):
import { CloakClient } from '@cloakapi/sdk';
const client = new CloakClient({ sanctumToken: sessionToken });
const { bytes, mimeType, filename } = await client.rebuildFile({ responseText: detokenisedText, aliasMap, // { conversationId, hmacPrefix, entries } targetFormat: 'docx', inputMime: 'application/pdf', fidelity: 'structural',});Rust (sdks/rust, following the existing count_tokens pattern in
sdks/rust/src/client.rs):
use cloakapi::CloakClient;
let client = CloakClient::with_sanctum_token(api_key, session_token)?;
let file = client.rebuild_file(&ReconstructRequest { response_text: detokenised_text, alias_map, // conversation_id, hmac_prefix, entries target_format: "docx".into(), input_mime: Some("application/pdf".into()), fidelity: Some("structural".into()),}).await?;Portal and desktop apps
Browser portal — a download button on the response bubble, next to the receipt
strip, lets you pick an output format and pulls the reconstructed file via
POST /v1/reconstruct using the alias map already held for that conversation.
Desktop app — the equivalent download button in the chat composer calls the same
endpoint for balanced/continuity tiers; at the max tier the Rust backend rebuilds
the file locally instead (no network round-trip, since detokenisation never leaves the
device at that tier).
Error codes
| Response | HTTP | When |
|---|---|---|
Laravel validation error ({"message": "...", "errors": {...}}) | 422 | Missing/invalid response_text, alias_map.*, or target_format not in the allowlist |
{"error": "Reconstruction failed", "code": <exit code>} | 422 | The cloak-reconstruct CLI process exited non-zero |
{"error": "Internal server error"} | 500 | Temp-file creation failed, or an unexpected exception |
{"error": "Internal JSON error"} | 500 | Alias map or CLI metadata failed to parse as JSON |
{"error": "Reconstruction produced empty output"} | 500 | The CLI produced zero output bytes |
{"verdict": "unverified", ...} | 403 | Authenticated user’s email is not verified (EnsureEmailVerified) |
{"verdict": "legal_acceptance_required", "redirect": "/onboarding"} | 403 | Authenticated user hasn’t accepted ToS/Privacy Policy yet (EnsureLegalAccepted) |
Unauthenticated. | 401 | No valid session (auth:sanctum failed) |
Source: ReconstructController.php:104-258, EnsureEmailVerified.php, EnsureLegalAccepted.php.
Zero-payload invariant: the gateway logs only the exit code and the first 200
characters of stderr on CLI failure — never file content, alias-map entries, or
response_text (ReconstructController.php:149-154,188-193). The alias-map temp file
and the output temp file are both shredded (@unlink) in a finally block regardless
of outcome.
Related
- File attachments — the ingest half of the round-trip
- Receipts — receipt envelope format and verification
- Privacy model — tokenisation tiers, including why
maxnever reaches the gateway with plaintext