PayPal · Sr. Content Designer · 2026

Content as code

I wrote the schema before anyone asked for one. Then the engine that resolves against it, the MCP server that hands it to agents, and the registry that renders it in a native iOS build.

Fintech · AI and agents

I built a content governance stack that runs end to end

Five months of work, from a locked schema to content rendering in a native app. Scroll to follow the path.

A locked JSON Schema
the schema everything else runs through
A TypeScript engine
resolves and renders content through it
A Model Context Protocol server
serves that content to agents
An HTTP bridge and a SwiftUI registry
an iOS build fetches it live, rendered through a design system
app / transfer
Couldn’t verify your account

Check your routing and account numbers and try again. They’re on the bottom of a check or in your banking app.

Try again

Governance and agent-readiness turned out to be the same list

card.savings.intro.jsonschema v1
{
"id": "card.savings.intro",
"headline": "Round up every purchase",
"cta": "Start rounding up",
"owner": "content-design",
"locked": true,
"legalBasis": "consumer-duty",
"lifecycle": "published",
"locales": ["en-US", "en-GB"],
"forbidden": ["seamless", "unlock"]
}

Governance needs these fields. So does an agent.

I spent weeks making two lists. One was what content needs to be governed: who owns it, whether it’s locked, what legal basis it sits on, where it is in its lifecycle, which locales it covers. The other was what an agent needs in order to use a string safely.

They were the same list. Governance isn’t overhead in the agent era, it’s the thing that makes agents usable at all. Everything below is me proving that in something you can run.

Here, drive it

Compose consolecard.savings.intro · v1

This is the content, stored as data. Edit a field and watch the component on the right. Then try to change the button.

headlineopen
supportingopen
call to actionlocked by compliance
lifecyclegovernance
owner content-designlocale en-USschema content-v1

Rendered component

Round up every purchase

We add the spare change to your savings goal. Turn it off any time in settings.

Start rounding up

not shippable in this state

Governance log

  • Token loaded and validated against schema v1

Left panel is the content, stored as data. Right panel is a component built from it. Edit a field and the component changes. Type a word off my own reject list and it gets blocked before it renders.

Then try to change the button.

A locked field with no way forward doesn't stop drift, it hides it

What most systems do

Locked string. Edit rejected. The person who needed different copy now has a ticket, a Slack thread, and a workaround.

What this does

Locked string. Edit rejected, and a variant is minted at v2 with the field open. The v1 record is untouched. Nothing was overwritten.

A locked string is a compliance record. Somebody in legal signed off on those exact words, and if they change quietly, the signoff is now attached to a sentence nobody approved. Every content tool I’ve used handles this the same way: it makes the field read-only and walks off. Which is correct, and useless, because the person who wanted to change it still has a real reason and now has no legitimate path.

So they find an illegitimate one. They ship a second string somewhere else. They put the new wording in a different component. They ask an engineer to hardcode it “just for this screen.” The lock didn’t prevent drift. It relocated it somewhere nobody is auditing.

The fix is boring and it comes straight from version control: you never edit a locked record, you mint a new variant from it. v1 stays exactly as approved, v2 exists with the field open, and the relationship between them is in the data. Compliance can see what changed and why there are now two. The person who needed different copy gets it in about a second, inside the system, where it can be reviewed.

That’s a content-design decision wearing engineering clothes. Deciding that a string is a record with a history rather than a value you overwrite is the same instinct as keeping a decision log instead of a style guide. I just wrote it down in something that runs.

I wasn't going to ask engineering to fund a hunch

Under 1%

Ungrounded output drops from roughly a third of responses to under one percent when generation is grounded.

Vectara HHEM

69 to 88%

How often legal models hallucinate on queries without grounding.

Stanford RegLab

Under 40% to near-total

Constraint adherence, once outputs are structured rather than free text.

Structured outputs research

Public research, not my numbers. It runs through the pitch, the schema, and the audit rules, because a platform ask needs a reason that survives someone else repeating it in a room I’m not in.

A domain model you can't run is just an opinion

I’m not an engineer and that isn’t the point. The hardest part of content as code is the domain model, and the domain model is content-design work. Building the engine, the validator, the bridge, and the native render was how I made the model impossible to wave away in a review.

None of it has shipped to customers. It was scoped as exploration, built to retire the technical risk on a platform bet, and it did that. The work now is adoption: aligning teams, and building the GitHub library and the MCP connector schemas that make it easy for someone else to say yes.

Four layers, so a sentence inherits its rules instead of being told them

compositionone token, four layers
{
"instance": "card.savings.intro",instance
"type": "promo-card",type
"pattern": "value-then-action",pattern
"primitives": {primitive
"tone": "plain",
"person": "second",
"readingLevel": 8
},
"headline": "Round up every purchase"
}

The model isn’t a diagram anywhere. It’s in the record.

Primitives are the smallest decisions, the ones that hold across every surface: plain tone, second person, an eighth-grade reading level. Patterns are shapes we’ve agreed on, like leading with the value before the action. Types are what a component will accept. Instances are the actual words.

Write a headline at the instance layer and it has already inherited the tone, the shape, and the shape’s constraints. Nobody has to remember the style guide, because the style guide is upstream of the field.

An agent that doesn't sound like you needs a list of things it may not say

Draft, before the reject list ran

A seamless way to unlock your savings potential.

  • seamlessuntestable filler, say what it does
  • unlockempty verb, name the action
What shipped

Round up every purchase and we'll move the spare change into savings.

When an agent doesn’t sound like you, the answer isn’t a better eval or a Likert scale. It’s a list of things it may not say. Take choices away and what comes out starts sounding like the team instead of generic AI cruft.

Evals catch what falls through. They don’t define the standard. The reject list runs before render, which is why the demo above refuses to publish a card with any of these in it.

Pull me into a conversation →

Project details

Company
PayPal
Role
Sr. Content Designer, Consumer Content Design
Scope
Settings, Profile, Activity, Subscriptions and Open Banking, Notifications
Timeline
2026, ongoing
Tools and methods
TypeScriptJSON SchemaMCPContract testsSwiftContent modelingDesign systemsClaude Code