---
title: "Shape caps on TipTap JSON"
excerpt: "An agent in a loop can write a 50MB doc. A one-line validator stops it. Three dimensions, one substrate-level gate, same cap for humans and agents — real users never trip it, agents-in-a-loop fail safely."
author: flint
category: Engineering
date: "2026-04-26"
image: /blog-mockups/style-d-dreamscape/shape-cap-on-tiptap.webp
---

The third time we caught an agent writing a doc the size of a small ebook, we accepted that the per-route validation we'd been adding wasn't enough. Different write paths had different validation. Some had none. The agent was finding the path with the weakest validation and using it.

The fix was a shape cap at the substrate level — one validator that runs *inside* the function that writes any doc, regardless of which surface called it. Three dimensions, hard limits, same cap for humans and agents. We've been running it for several months. Real users have not tripped it once. Agents in misbehaving loops trip it cleanly and fail safe.

This piece is what the cap looks like, why three dimensions, and the discipline that keeps the cap useful.

## The substrate-level gate

The write surface for a doc is `writeDocBody(workspace, content, principal)`. Every path that updates a doc — REST, MCP `update_doc`, the live-collab WebSocket, internal admin endpoints — ends up calling this function. By design.

Inside `writeDocBody`, before anything is written, we call `validateDocShape(content)`. The validator throws `DocGuardError` if the content fails any of the three dimensions. The throw is caught by the API layer and mapped to a 413 (Payload Too Large) with structured detail.

```typescript
async function writeDocBody(workspace, content, principal) {
  validateDocShape(content);  // throws DocGuardError on cap miss
  // …rest of the write path…
}
```

The point of putting it in `writeDocBody` rather than in each route's request handler: it's a one-line addition to every existing write path that already calls the function (just upgrading them to use the substrate function), and any new write path automatically inherits the cap. We don't have to remember to validate.

## The three dimensions

A doc is bounded in three orthogonal ways:

**Bytes.** The serialized JSON body is capped at 1 MB. Most real prose docs we see are under 100 KB. The 1 MB cap is 10x typical, so real users don't trip it; agents writing nested gibberish trip it within a few iterations.

**Depth.** The TipTap content tree is capped at 10 levels deep. Nested lists inside blockquotes inside other lists are real but rarely go past 5 deep. 10 is generous; 50-deep is suspicious.

**Node count.** Total number of TipTap nodes in the document is capped at 5000. This catches docs that are *flat* but absurdly long — 10,000 paragraphs of one sentence each.

Each dimension catches a different kind of failure. A doc that's 50 MB hits the bytes cap. A doc that's 200 levels deep hits the depth cap. A doc that's 100,000 short paragraphs hits the node-count cap. You need all three because no single one catches all the bad shapes.

## Why the cap is the same for humans and agents

The temptation when shipping caps is to apply them only to agents — "humans are fine, but constrain the agents." This is wrong for two reasons.

**Humans-as-routes don't exist.** The validator runs inside `writeDocBody`. The function doesn't know whether a human or an agent triggered the write. It knows the workspace and the principal type, sure, but baking principal-type-aware caps into the substrate is a maintainability hazard. The cap is a property of the data, not of the actor.

**Real prose docs are well under the cap.** The 1 MB / 10-deep / 5000-node cap was chosen to be 10–50x typical document size. Real human work doesn't trip it. The cap doesn't need a "human exception" because there's no human use case the cap rejects.

**Symmetry is a feature.** When something fails the validation, the user sees the same error message regardless of whether their human edit or their agent edit triggered it. The error is "your doc is too large; trim X." No special casing.

## The error shape

When the cap is tripped, we don't just throw a generic 500. The error is structured:

```typescript
class DocGuardError extends Error {
  constructor(public limit: string, public observed: number, public cap: number) {
    super(`Doc shape cap exceeded: ${limit} = ${observed} (cap ${cap})`);
  }
}
```

The API maps this to a 413 with detail:

```json
{
  "error": "payload_too_large",
  "detail": {
    "limit": "depth",
    "observed": 47,
    "cap": 10
  }
}
```

The structured detail lets the client say "your doc is 47 levels deep; the cap is 10" without parsing the message. For an agent calling the API, this is useful — the agent can see which dimension tripped and (sometimes) take the corrective action.

For a human, the UI surfaces the structured detail as "your document is too large; nesting depth 47 / cap 10." The user understands what to fix.

## The discipline of choosing the limits

Picking the right caps is the actual hard part. Too tight and real users trip them; too loose and agents-in-a-loop don't fail until they've done damage.

How we picked ours:

1. **Sample real docs.** We pulled 1000 production docs across various users. Computed bytes, depth, node count for each.
2. **Find the 99th percentile.** What's the largest real-prose doc by each dimension?
3. **10x it.** Set the cap at 10x the 99th percentile. Real outliers still pass; agents-in-a-loop don't.
4. **Watch for trips.** When a real user trips the cap, ask why. Is the cap too tight, or is the user doing something genuinely unusual?

The 10x heuristic is conservative but it's worked. We've had three real-user trips in three months, all of which were edge cases (one was a doc with a deeply nested table of contents, fixable by trimming). No false-positive cap-trips that broke real work.

For agent-in-a-loop trips, we've had dozens, all caught cleanly. The agent gets a 413, the loop terminates, no data is written.

## What this composes with

Shape caps are one of the five primitives in [AI-agent-first primitives](/blog/ai-agent-first-primitives). They compose with:

- **Agent identity** — the cap doesn't care about the principal, but the audit log does. When the cap is tripped, we log which agent tripped it. Patterns become visible.
- **Consent gates** — orthogonal protections. The cap stops volume; the gate stops dangerous operations. Different layers.
- **Per-workspace permissions** — the cap is global, but the workspace permissions decide who can write *at all*. The cap is the second line, not the first.

We've also added analogous caps elsewhere: row count per workspace (50,000 on Scale tier), comment thread depth (50), search result size. Each follows the same pattern: substrate-level gate, three-or-so dimensions, real-user-safe limits.

## What we'd do differently

A few things we'd build differently if we shipped this fresh:

**Per-org caps.** Right now the cap is global. Letting an enterprise customer raise their own cap (with admin sign-off) would be reasonable. We don't ship this yet because no customer has asked.

**Soft-warn at 80%.** When a doc approaches the cap, we don't warn the user. They just hit the wall. Soft warning at 80% would be friendlier — "your doc is at 800 KB, approaching the 1 MB cap."

**Cap visibility in the API.** Currently the cap is a constant in our code. Exposing it via a `GET /api/limits` endpoint would let agents check before writing. We don't ship this; agents trip and learn instead. Trade-off acceptable for now.

These are nice-to-haves. The base cap is the substrate-level gate, and the substrate-level gate is the protection.

## Cross-links

- [AI-agent-first primitives](/blog/ai-agent-first-primitives) — the broader primitive set.
- [The dangerous-ops contract](/blog/dangerous-ops-contract) — the orthogonal protection for irreversible operations.
- [Why agents need their own identities](/blog/why-agents-need-identities) — the prerequisite for principal-aware audit logging when the cap trips.

## FAQ

**What's a shape cap?**

A hard limit on the structure of a write — bytes, depth, node count, etc. — enforced at the substrate level so every write path inherits it. Agent-in-a-loop disasters trip the cap and fail safely; real users don't notice the cap exists.

**Why three dimensions and not just bytes?**

Different bad shapes hit different limits. A 50 MB doc hits bytes. A 200-deep nested structure hits depth even if the bytes are fine. 100,000 short paragraphs hit node count even if depth and bytes are fine. You need all three because no single dimension catches everything.

**Why is the cap the same for humans and agents?**

Real prose docs from humans are well under the cap (10x or more headroom). The cap doesn't need a human exception because no human use case it rejects exists. Symmetry also simplifies the error UX — same message, same error code, regardless of who tripped it.

**How do you choose the right limit?**

Sample real docs, find the 99th percentile per dimension, set the cap at 10x the 99th. Real users at the long tail still pass; agents-in-a-loop trip cleanly. Watch the trip rate over time and adjust if real users start hitting the wall.

**What happens when the cap is tripped?**

The write fails with a 413 (Payload Too Large) and a structured detail showing which dimension tripped and the observed vs cap value. The agent (or the human user) gets a clear error and can take corrective action.

<!-- json-ld -->

```json
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Shape caps on TipTap JSON",
  "description": "An agent in a loop can write a 50MB doc. A one-line validator stops it. Three dimensions, one substrate-level gate, same cap for humans and agents.",
  "datePublished": "2026-04-26",
  "author": { "@type": "Person", "name": "Flint" },
  "publisher": { "@type": "Organization", "name": "Dock", "url": "https://trydock.ai" },
  "image": "https://trydock.ai/blog-mockups/style-d-dreamscape/shape-cap-on-tiptap.webp",
  "mainEntityOfPage": "https://trydock.ai/blog/shape-cap-on-tiptap"
}
```
