The Forward Deployed

Anthropic Interview: Design a Prompt Playground

A full solution to the Anthropic prompt playground question: the immutable version model, sharing scopes and revocation, very large prompts, concurrent tabs, deletes, and export to an API call.

By Reviewed

Part of the Anthropic system design question bank. The question is representative of the round. The analysis and solution are this site's own.

Problem statement

A developer opens the playground, writes a system prompt and a few messages, picks a model and settings, and runs it. They tweak and rerun, save the versions they like, and send a link to a teammate. Later they export the winning version as code. The interviewer supplies a three-box diagram and asks you not to draw more infrastructure.

Billing, model serving, and team administration are out of scope.

Clarifying questions

  • Who uses it? Developers building on the API. Assume individual accounts that belong to organizations.
  • How big can a prompt get? Most are a few kilobytes. Some include whole documents or codebases, up to 10 MB.
  • What sharing is needed? Public links, links for any logged-in user, and invites to specific accounts, each as viewer or editor.
  • Is there live co-editing? No. Two people editing the same prompt is handled as a conflict, not merged keystroke by keystroke.
  • How long is history kept? Every saved version, indefinitely, unless the prompt is deleted.
  • Are runs saved? Yes, each run records its version, settings, and output, so results can be compared.

What makes a playground hard

The traffic is low. The data model is the whole problem.

A prompt is not one value. It changes constantly, people want to go back, two tabs edit it at once, and links to it travel to people with different rights. If you store "the prompt" as one mutable row, every one of those needs becomes a special case: history needs a separate audit table, undo needs snapshots, sharing a specific state needs a copy, and concurrent edits overwrite each other.

So the driving tension is a simple mutable model versus correct history, sharing, and concurrency. The answer is to make versions immutable and move a pointer, which turns most of those special cases into ordinary reads.

flowchart LR
  B([Browser]):::user --> W[Web server]:::svc --> D[(Database)]:::store
  W --> O[(Object storage<br/>large bodies)]:::store
  W --> M[Model API]:::svc
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
Key idea. The interviewer fixed the architecture so the grade comes from the data model. Immutable versions plus a movable pointer solve history, undo, sharing a snapshot, and concurrency with one idea.

Key concepts

Immutable versions

Each save creates a new version row that never changes. It records its parent, which is the version it was edited from. The prompt row holds a pointer to its latest version. History is a walk up the parents. Undo moves the pointer back. Two edits from the same parent create two children, which is a branch, with no lock needed.

Content addressing

A version's body is stored by its hash. Two versions with the same body point to the same stored object. Large bodies stored once save space when a user changes only the settings.

Compare-and-set

Moving the latest pointer is a conditional write: set latest to the new version only if it still equals the version the editor started from. If another tab moved it first, the write fails, and the client knows to show a conflict.

Scopes and roles

A scope says who can open a link: anyone, any logged-in user, or listed accounts. A role says what they can do: view, comment, or edit. Keep them separate. The same prompt can have a public view link and an editor invite at the same time.

Key idea. Immutable versions, content-addressed bodies, a compare-and-set pointer, and separate scopes and roles are the four building blocks.

  1. Requirements

Before reading on. List the requirements. Then decide which one property you would protect above all, and what the real constraint is, given that traffic is tiny.

1.1 Functional requirements

  • Create, edit, and save prompts with model settings.
  • Run a prompt and stream the output. Save each run.
  • View history, compare versions, restore an old version.
  • Share by public link, logged-in link, or account invite, with a role. Revoke any share.
  • Handle prompts up to 10 MB.
  • Delete a prompt, and restore it within a retention window.
  • Export a version as an API request in curl or SDK code.

1.2 Non-functional requirements

  • No lost work. A save that returns success is durable, and one tab never silently overwrites another.
  • Correct access. A revoked user loses access within a stated bound.
  • Fast editing. Opening a prompt and switching versions feel instant, even for large bodies.
  • Cheap storage. Many versions of a 10 MB prompt must not multiply storage by the version count.

1.3 The constraint versus the property

No lost work is the property to protect. Developers iterate for hours; losing an edit to a concurrent tab or a failed save destroys trust. Payload size is the constraint that shapes the design. Scale in users is trivial, but a 10 MB body per version drives the storage layout, the upload path, and the rendering path.

Key idea. Protect saved work. Design around body size, because user scale is not the problem.

  1. Back-of-the-envelope estimation

For practice: one million registered users, 10,000 daily active users, 20 saves and 30 runs each per day.

2.1 Request rates

Saves: 10,000 × 20 = 200,000 a day, about 2.3 per second on average. Runs: 300,000 a day, about 3.5 per second. Reads, such as opening prompts and history, run at a few times that. One relational database handles this with a large margin. Say so and move on.

2.2 Storage

Assume the average body is 4 KB, and 1% of saves carry a 5 MB body.

Small bodies: 198,000 × 4 KB ≈ 0.8 GB a day. Large bodies: 2,000 × 5 MB = 10 GB a day. Over a year that is about 3.9 TB, dominated by large bodies. If large edits are stored as diffs averaging 50 KB, the large-body cost drops to 0.1 GB a day, and the year total falls to about 330 GB.

That arithmetic is why diffs and deduplication matter here, even though request rates are trivial.

A public link posted to a busy forum can draw thousands of opens in minutes. That is the one read spike to plan for, and it only touches immutable data.

Key idea. Request rates are tiny. Storage for large bodies and the occasional viral read are the only numbers that shape the design.

  1. API design

Before reading on. A save request arrives from a tab that loaded version 41, but the latest is now 42. What should the API return?

It should refuse to move the pointer and return a conflict with the current latest version. The client then shows both versions and lets the user choose. The new version can still be stored, as a branch, so the user's work is never lost.

3.1 Prompts and versions

POST /v1/prompts                          {title}
  -> {prompt_id}

POST /v1/prompts/:id/versions
  {parent_version_id, body_ref | body, model, settings}
  -> 201 {version_id, latest: true}
  -> 409 {version_id, latest: false, current_latest_id}   // saved as a branch

GET  /v1/prompts/:id                      -> {title, latest_version_id, owner, role}
GET  /v1/prompts/:id/versions?cursor=     -> page of {version_id, parent, author, created_at}
GET  /v1/versions/:vid                    -> {model, settings, body_ref, body_hash, size}
POST /v1/prompts/:id/restore              {version_id}   // moves latest, creates no copy

3.2 Large bodies

POST /v1/uploads          {size, sha256}
  -> {upload_id, part_urls[]}      // presigned multipart URLs, or "exists": true
PUT  <part_url>                   (browser to object storage directly)
POST /v1/uploads/:id/complete     -> {body_ref}

If the hash already exists, the server answers "exists" and the client skips the upload.

3.3 Sharing

POST   /v1/prompts/:id/shares     {scope: public|link|accounts, role, version_id?, expires_at?}
  -> {token, url}
DELETE /v1/shares/:token
POST   /v1/prompts/:id/acl        {account_id, role}
DELETE /v1/prompts/:id/acl/:account_id
GET    /s/:token                  -> the shared prompt or version, if allowed

3.4 Runs and export

POST /v1/versions/:vid/runs       -> stream of tokens, then {run_id, usage}
GET  /v1/versions/:vid/export?format=curl|python|typescript
Key idea. Saves carry their parent, conflicts save as branches, large bodies upload straight to storage by hash, and shares are separate objects with their own tokens.

  1. Data model

4.1 Tables

prompt      (id, org_id, owner_id, title, latest_version_id,
             created_at, updated_at, deleted_at)
version     (id, prompt_id, parent_version_id, author_id, created_at,
             body_ref, body_hash, body_size, body_kind,   -- inline | blob | diff
             base_version_id NULL,                          -- for diffs
             model, settings_json)
run         (id, version_id, user_id, created_at, output_ref, usage_json, status)
share_link  (token, prompt_id, version_id NULL, scope, role,
             created_by, created_at, expires_at, revoked_at)
acl_entry   (prompt_id, principal_id, role, granted_by, created_at)
blob        (hash, size, storage_key, ref_count, created_at)

4.2 Indexes

prompt(owner_id, updated_at desc)            "my prompts" list
prompt(org_id, updated_at desc)              org library
version(prompt_id, created_at desc)          history
share_link(token) unique                     open a link
acl_entry(principal_id, prompt_id)           "shared with me"
acl_entry(prompt_id)                         who has access

4.3 Why the version row holds settings

A version freezes everything that affects output: model name, temperature, maximum tokens, stop sequences, tools, and the body. A run points to a version, so any old result can be explained and reproduced. Settings stored on the prompt row would let a later change rewrite what old runs appear to have used.

Key idea. The version is the unit of truth. It freezes body and settings together, and everything else points to it.

  1. High-level design

5.1 One mutable row

The simplest design stores each prompt as one row with its text and settings, updated in place.

flowchart LR
  B([Browser]):::user --> W[Web server]:::svc --> P[(prompt row<br/>text, settings)]:::store
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

It fails on the first real use. Two tabs overwrite each other. There is no history. A shared link shows whatever the text is now, not what the sender saw. A 10 MB body sits in a database row and ships to the browser on every open.

5.2 Fix 1: immutable versions and a pointer

Each save writes a new version and moves the prompt's latest pointer with a compare-and-set.

flowchart LR
  W[Web server]:::svc --> P[(prompt<br/>latest_version_id)]:::store
  W --> V[(version rows<br/>immutable, parent pointer)]:::new
  P -.points to.-> V
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

History, undo, and branches now come for free. Concurrent tabs cannot overwrite each other, because the pointer only moves from the version the editor started with.

5.3 Fix 2: move bodies to object storage

Bodies above a threshold, such as 64 KB, go to object storage, addressed by hash. The browser uploads directly with presigned URLs. The database holds only references.

flowchart LR
  B([Browser]):::user -->|1. hash + size| W[Web server]:::svc
  W -->|2. presigned parts or exists| B
  B -->|3. upload parts| O[(Object storage<br/>by sha256)]:::new
  B -->|4. save version with body_ref| W --> V[(version)]:::store
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

The web server never carries 10 MB through its memory, and identical bodies upload once.

5.4 Fix 3: sharing as its own object

Shares get their own table with a token, a scope, a role, an optional pinned version, and a revocation time. Direct account access lives in an ACL table. A request for a prompt checks ownership, then the ACL, then the share token.

flowchart LR
  R[Request for prompt]:::svc --> O{Owner?}:::svc
  O -->|no| A{ACL entry?}:::svc
  A -->|no| T{Valid share token?<br/>scope, not revoked, not expired}:::new
  T -->|no| X[403]:::bad
  O -->|yes| OK[Allow with role]:::store
  A -->|yes| OK
  T -->|yes| OK
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
  classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.5 Fix 4: cache what never changes

Versions never change, so anything keyed by version ID can be cached forever: in the browser, on a CDN, and in the server. Permission checks are cached briefly, with invalidation on change.

5.6 The composed design

sequenceDiagram
  autonumber
  actor U as Developer
  participant B as Browser
  participant W as Web server
  participant D as Database
  participant O as Object storage
  participant M as Model API
  U->>B: edit and click Save
  B->>W: POST versions {parent=41, body or body_ref}
  W->>D: insert version 43 (parent 41)
  W->>D: UPDATE prompt SET latest=43 WHERE latest=41
  alt pointer moved
    W-->>B: 201 latest
  else someone saved 42 first
    W-->>B: 409, branch saved, current latest=42
  end
  U->>B: click Run
  B->>W: POST versions/43/runs
  W->>O: fetch body (if blob)
  W->>M: stream request
  M-->>W: tokens
  W-->>B: tokens
  W->>D: insert run with usage
Key idea. Each fix removes one failure of the mutable row: versions for history and conflicts, object storage for size, share objects for access, and caching for the read spike.

  1. Deep dives

6.1 Revocation and stale access

Before reading on. A user removes a teammate's access. For how long can the teammate still read the prompt, and is that acceptable?

Three places can serve stale access.

Permission caches. If the server caches a permission decision for 30 seconds, a revoked user keeps access for up to 30 seconds. Write-through invalidation shrinks that: on revoke, delete the cache entry for that prompt. Keep the time-to-live as a backstop in case an invalidation message is lost.

Signed URLs for bodies. Bodies are served through presigned URLs that expire in a few minutes. Revocation stops new URLs from being issued, but a URL already handed out works until it expires. Keep the expiry short, 5 to 15 minutes, and say that this is the bound.

Browser copies. Anything the teammate already loaded stays on their screen. No server design can take it back. Say so plainly.

State the result as a bound: new requests are denied within seconds, body URLs die within 15 minutes, and content already viewed cannot be recalled. For most prompt content that is acceptable. For secrets pasted into a prompt, the right fix is to rotate the secret.

What separates answers: revocation

WeakAssumes revocation is instant

Deletes the ACL row and stops, unaware of caches and signed URLs.

GoodNames the caches and their bounds

Explains the permission cache and signed URL expiry, and gives a time bound for each.

StrongStates the full contract

Gives the bound for new requests, for body URLs, and for already-viewed content, and says which content needs a stronger response than revocation.

6.2 The 10 MB path

Before reading on. A user edits one line of a 10 MB prompt 50 times. How much storage and bandwidth does that cost with your design?

Naively, 50 × 10 MB = 500 MB of storage and as much upload bandwidth.

With diffs, each save uploads and stores only the changed part, measured against the parent. Every tenth version, or when the diff chain grows past a size limit, store a full snapshot so that rebuilding any version needs at most a few diffs. The version row records whether its body is inline, a full blob, or a diff against a base version.

Rendering matters too. A 10 MB string in a browser text area makes typing lag. Use an editor that virtualizes lines, and fetch the body by byte range, so the first screen appears before the whole file arrives. Cache bodies in the browser by version ID in IndexedDB, which is safe forever because versions never change.

Before a run, count tokens against the model's context window and warn if the prompt will not fit. A 10 MB body can exceed the context of every model.

flowchart LR
  V1[v1: full 10 MB]:::store --> V2[v2: diff 3 KB]:::svc --> V3[v3: diff 1 KB]:::svc --> V10[...]:::svc --> V11[v11: full snapshot]:::store --> V12[v12: diff 2 KB]:::svc
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

6.3 Many tabs and conflicts

Before reading on. A user has the same prompt open in three tabs and saves in two of them a few seconds apart. What does each tab see?

Each save carries the parent version its tab loaded. The first save moves the pointer from 41 to 42. The second save, also from 41, fails its compare-and-set. The server stores it as version 43, a sibling of 42, and returns 409 with the current latest. The second tab shows a banner: "This prompt changed in another tab. Keep yours, keep theirs, or compare." Keeping theirs discards nothing, because version 43 stays in history.

Across tabs in one browser, a BroadcastChannel can tell the other tabs about a save as it happens, so they update before the user edits stale text. That is a nicety. The compare-and-set is the guarantee.

What separates answers: concurrency

WeakLast write wins

Saves overwrite the row, and the earlier tab's work disappears.

GoodDetects the conflict

Uses a version check and shows a conflict instead of overwriting.

StrongNever loses work

Saves the losing edit as a branch, explains compare-and-set on the pointer, and uses cross-tab messaging to reduce conflicts without relying on it.

6.4 Deletes and restores

Soft-delete by setting deleted_at on the prompt. Every read path filters on it, so the prompt disappears at once. Share links to it return "This prompt was deleted" and not a generic 404, which saves support tickets.

A purge job runs after the retention window, such as 30 days. It deletes version rows and decrements the blob reference counts. A blob is deleted only when its count reaches zero, because another prompt may share the same body by hash. Restoring within the window clears deleted_at, and every link and ACL entry works again, since none of them were touched.

A public link to a pinned version is immutable content. Serve the rendered page and the body from a CDN, keyed by version ID, with a long cache lifetime. Store the scope on the share row so the check for a public link is one indexed lookup with no ACL join. If the link is revoked, purge the CDN entry for that token.

6.6 Export

Render the version into a real API request: model, settings, system prompt, and messages. Offer curl, Python, and TypeScript. Keep the API key out of the export, and use an environment variable placeholder. This small feature is where the playground connects to production, and it shows you know what a version must freeze.

6.7 Comparing versions

Before reading on. A developer wants to see what changed between version 12 and version 19 of a 2 MB prompt, and how the outputs differ. How do you build that?

Two comparisons matter: the prompt and the result.

For the prompt, compute a line-based diff between the two bodies, and show settings changes as a table: model, temperature, and any other field that differs. Diffing 2 MB bodies in the browser is fine for text; do it in a web worker so the page stays responsive. For very large bodies, compute the diff on the server once and cache it by the two version IDs, since versions never change.

For results, the developer wants to run both versions on the same inputs. Model it as a comparison run: a set of inputs, two versions, and one output per input per version, displayed side by side. Because each run records its version, results remain comparable weeks later, even after both versions have newer children.

The cost can surprise people. Running two versions of a 2 MB prompt, roughly 500,000 tokens, on 20 inputs is 20 million input tokens. Show the estimated cost before the run and require confirmation above a threshold. Prompt caching on the provider side helps when the large body is a shared prefix.

6.8 Access control at the database level

Before reading on. A developer adds a new endpoint that lists recent versions. They forget to check permissions. What stops it from leaking other users' prompts?

Application checks alone rely on every endpoint remembering them. Add a second layer. Two common options:

  • Row-level security in the database. Every query runs with the requesting user's ID in the session, and a policy on each table filters rows to those the user can access: owned, shared by ACL, or reachable by a valid share token. A forgotten check in application code returns an empty result instead of someone else's data.
  • A single data-access layer. All reads go through one module that takes the caller's identity and applies the access check. Direct table access from handlers is banned in code review and by lint rules.

Either way, add a test suite that calls every endpoint as a user without access and expects nothing back. It is the playground's version of a permission test set.

What separates answers: access control

WeakChecks in each handler

Relies on every endpoint remembering to check permissions.

GoodOne access-check layer

Centralizes access checks in one module or middleware used by all reads.

StrongDefense in depth with tests

Adds database-level row policies or an enforced data-access layer, and a test that calls every endpoint as an unauthorized user.

  1. Variants

7.1 Live co-editing

If the interviewer adds real-time collaboration, the version model stays as the history layer. Live editing happens in a session using operational transforms or a CRDT, and the session saves versions at checkpoints. Say that this is a large addition and ask whether it is in scope before designing it.

7.2 Evaluation runs

A common extension runs one version against a set of test inputs and scores the outputs. Model it as an eval set table, an eval run that points to a version and a set, and per-case results. Immutable versions make eval results trustworthy, because the prompt under test cannot change afterward.

7.3 Organization libraries

Teams want shared, curated prompts. Add an org-level scope and a "published" flag on a version. Published versions are what other teams reference, and the author keeps iterating on drafts without breaking them.

7.4 At ten times the traffic

The design barely changes, which is worth saying. At 100,000 daily users, saves reach about 25 per second and runs about 35 per second: still one primary database with read replicas. The first pressure is the viral link, which the CDN already handles, and storage for large bodies, which grows linearly and is already in object storage. The second is run throughput against the model API, which needs per-organization rate limits. Sharding the database by organization becomes worth considering only well beyond that.

  1. The transferable pattern

The playground is an immutable log of snapshots with a movable head, the same shape as git, document history, and configuration management. When a question involves editing, history, sharing a specific state, and concurrent writers, reach for that shape first. Most of the hard cases then become reads.

Review: the 30-second answer

  • Immutable versions with a parent pointer. History, undo, and branches for free.
  • Compare-and-set on the latest pointer. Concurrent tabs never overwrite; the losing edit becomes a branch.
  • Bodies in object storage by hash. Direct upload, deduplication, diffs with periodic snapshots.
  • Shares as objects with scope and role. Revocation bound stated for caches and signed URLs.
  • Cache by version ID forever. It makes viral links and tab switches cheap.

Quiz

+Why store versions as immutable rows with a parent pointer?

Because history, undo, sharing a specific state, and concurrent edits all become simple. History is a walk up the parents, undo moves a pointer, a share can pin a version that never changes, and two edits from one parent become a branch instead of an overwrite.

+What does the compare-and-set on the latest pointer prevent?

A lost update. A save only moves the pointer if it still points to the version the editor started from. If another tab moved it first, the save fails, and the client shows a conflict while the edit is kept as a branch.

+Why does revocation have a delay, and how long is it?

Permission decisions may be cached, and body files are served by signed URLs that stay valid until they expire. With write-through invalidation, new requests are denied within seconds. Existing signed URLs last up to their expiry, such as 15 minutes. Content already on the viewer's screen cannot be recalled.

+Why diffs plus periodic snapshots instead of diffs alone?

Rebuilding a version from diffs alone means replaying every diff since the first version, which grows without limit. A full snapshot every N versions caps the rebuild at N diffs.

+When can a blob be deleted after a prompt is purged?

Only when no version references its hash anymore. Bodies are shared by content hash across versions and prompts, so the purge decrements a reference count and deletes the blob at zero.

+Why cache a diff between two versions forever?

Versions are immutable, so the diff between two version IDs never changes. Computing it once and caching it by the pair of IDs is always correct.

+What does row-level security add on top of application permission checks?

It enforces access in the database itself, so a query from an endpoint that forgot its permission check still returns only rows the user may see.

Sources and further reading

NextModel Weight Distribution