The Brief
The previous posts in this series — porting a desktop Java client to Android and then to iOS — argued that a small team can vibe-code client-side apps to production over a handful of working days. Both ports relied on a pre-existing reference server. This time, we asked the obvious follow-up question: how close are we to the same workflow on the server side? Can a model take the published JSON API spec and stand up a complete backend that actually talks to our open-source reference client at github.com/Secchats-tech/ChatJavaUI — without a human having to remember which endpoints exist or what each one returns?
The experiment had three steps. First, ask Claude to produce a single self-contained prompt — a prompt that another LLM could read and use to build the server end-to-end. Second, hand that prompt to Gemini and see what came out. Third, hand the same prompt to Claude itself, building the server from its own specification. The three conclusions at the bottom of this post lay out where the wall is today: there's a lot the models already do well, and a smaller, sharper set of things that still need human judgment before the round-trip can be one-shot. Spoiler — we got two working servers, both gated by integration bugs that lived outside the spec.
The Prompt
We asked Claude to write a prompt that any frontier LLM could use to one-shot the
server. The model's first cut was tight but missed a real thing — the
spec page elided several fields that the reference Java
serializer always emits (timestamp on every message-shaped object,
lastMessageTimestamp on User, algorithm and
privateKey on a newly created Group). We had Claude
re-audit the actual model classes against the spec, and it produced a second version
with an explicit "wire-format notes" section calling out the always-emitted fields and
the lenient-parse / strict-emit rule. That's the version we shipped to both
Gemini and itself.
Click to expand the full prompt (one self-contained block, ~270 lines)
You are an expert Python backend engineer. Build a complete, runnable
HTTP API server implementing the SecChats JSON API specified at
https://secchats.com/api.html.
The spec is the contract. Read it. The summary in this prompt is a
fallback when you don't have web access — implement the spec's shapes
verbatim, not a paraphrase.
Stack (default — swap only with a written reason)
FastAPI + Pydantic + stdlib sqlite3 + uvicorn. No ORM. Async endpoints
throughout because /longpoll requires a real wait-notify primitive, not
a polling loop.
Endpoints (all POST, all Content-Type: application/json)
/register — Create a user → return userId + authToken. No
/register2 — Create a group → return groupId. Yes
/receive — Notifications since lastMessageTimestamp. Yes
/connect — Friend request (DH public key) to target user. Yes
/connect2 — Reply to a friend request. Yes
/postmsg — Send an encrypted direct message. Yes
/groupinv — Deliver an encrypted group key to a user. Yes
/postgroup — Send an encrypted group message. Yes
/pollgroup — Group messages since timestamp. Yes
/pollgroups — Multi-group variant of /pollgroup. Yes
/longpoll — Block up to 25s waiting for any of N groups to
receive a message for the caller. Plain-text
response. Yes
Canonical body shapes (every always-emitted field, in declaration order)
The reference Java serializer (ChatSerializer) emits every declared
field of every model by reflection, in declaration order, with a
leading "object" discriminator. Mirror that exactly.
// /register response
{ "object":"User", "userId":"<hex>", "name":"",
"authToken":"<token>", "lastMessageTimestamp":0 }
// /register2 response (newly created Group)
{ "object":"Group", "groupId":"<id>", "name":null,
"algorithm":null, "privateKey":null,
"lastMessageTimestamp":0 }
// /receive request body
{ "object":"User", "userId":"<id>", "name":"",
"authToken":"<token>",
"lastMessageTimestamp":1712345678000 }
// /receive response (Notifications)
{ "object":"Notifications",
"friendRequests":[ { "object":"FriendRequest",
"fromUserId":"<id>", "authToken":null,
"toUserId":"<id>", "timestamp":1712345678000,
"protocol":"DH_AND_AES", "cryptoData":"<base64>" } ],
"friendResponses":[ ... ],
"groupInvites":[ ... ],
"messages":[ { "object":"SendMessage", "toId":"<id>",
"authToken":null, "fromUserId":"<id>",
"encContent":"<base64>",
"timestamp":1712345678000 } ] }
// /connect request
{ "object":"FriendRequest", "fromUserId":"<id>",
"authToken":"<token>", "toUserId":"<target>",
"timestamp":0, "protocol":"DH_AND_AES",
"cryptoData":"<base64-dh-public-key>" }
// /postmsg request (toId = recipient userId)
// /postgroup request (toId = groupId — same shape)
{ "object":"SendMessage", "toId":"<target>",
"authToken":"<token>", "fromUserId":"<id>",
"encContent":"<base64>", "timestamp":0 }
// /longpoll response: plain text — group id that fired or "".
Wire-format notes (the spec page elides some always-present fields)
- Every model emits every declared field in declaration order —
null and 0 included. Mirror that exactly on responses.
- Strip authToken (set to null) on anything echoed back from the
notification queue, but keep the field in the JSON.
- Clients sometimes omit timestamp:0 and lastMessageTimestamp:0 on
send. Accept gracefully on parse, emit always on serialise. With
Pydantic: model_config = ConfigDict(extra='ignore') plus
Field(default=0) on every long.
- name is "" for users and null (or "" — be tolerant) for newly
created groups. The server never persists or inspects it.
Storage (zero-trust — server stores the absolute minimum)
CREATE TABLE principals (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('user','group')),
auth_token_hash BLOB,
created_at INTEGER NOT NULL
);
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_principal TEXT NOT NULL REFERENCES principals(id),
kind TEXT NOT NULL CHECK (kind IN
('FriendRequest','FriendResponse',
'GroupInvite','SendMessage')),
from_user TEXT,
group_id TEXT,
protocol TEXT,
algorithm TEXT,
crypto_data BLOB,
enc_content BLOB,
ts_ms INTEGER NOT NULL
);
CREATE INDEX idx_notif_to_ts
ON notifications(to_principal, ts_ms);
Hard invariants — fail the build mentally if any are violated:
- authToken is never persisted. Store HMAC_SHA256(SECCHATS_HMAC_SECRET,
token) and constant-time compare via hmac.compare_digest.
- name field is sent and stored as "". Never index, log, or echo
display names. They live only inside encContent.
- cryptoData / encContent are opaque blobs — store and return
untouched.
- Per-recipient queues: friend-request / friend-response /
group-invite / direct-message rows keyed on the target user. Group
messages keyed on the group.
- No plain auth tokens or message content in logs.
Behavioural invariants
- All non-/register endpoints validate userId + authToken against
principals.auth_token_hash. Mismatch ⇒ HTTP 429 plain-text body
"Rate limited." (per the spec's error table).
- /connect and /groupinv ⇒ 404 if target user / group doesn't exist.
- Polling endpoints return only rows with ts_ms > lastMessageTimestamp.
- When emitting any queued item, set authToken to null before
serialise (mirrors the reference server's clearAuthToken()).
- /longpoll implementation: a dict[str, list[asyncio.Event]] keyed by
user id. On any insert into notifications for a user, set + drain
that user's events. Endpoint awaits with
asyncio.wait_for(..., timeout=25). Return "" on TimeoutError. No
while True: sleep.
- Per-IP token-bucket rate limit on every endpoint: 30 requests / 5
seconds. Excess ⇒ 429 plain text.
- Timestamps are int(time.time() * 1000) everywhere.
Deliverables (single project directory)
server/
app.py # FastAPI app, all endpoints, Pydantic models
store.py # SQLite helpers + async event registry
schema.sql # the DDL above
test_api.py # pytest, one happy-path per endpoint
requirements.txt
README.md # venv setup, run, test
Must run with:
pip install -r requirements.txt
SECCHATS_HMAC_SECRET=test uvicorn app:app --port 8080
pytest -q
What to avoid
- ORMs.
- Endpoints not in the spec.
- Polling loops for /longpoll.
- Storing or logging cleartext tokens, display names, or message
bodies.
Definition of done
- pytest -q is green.
- Round-trip /register x 2 → A /connects B → B /receives the friend
request with the exact wire shape.
- A second user /longpolling on a shared group is woken within ms
when the first user /postgroups to it.
- sqlite3 db.sqlite '.dump' shows no plaintext tokens, no display
names, and no plaintext message content.
Build it. Output the full file tree with file contents inline.
Two things to flag about the prompt itself, since they map directly onto two of the conclusions at the bottom:
- The prompt declares stack (FastAPI + stdlib sqlite3 + Pydantic + uvicorn), schema, zero-trust invariants, and the definition of done. That last piece — a runnable rubric — turns out to do more work than any prose paragraph.
- The wire-format notes section exists because the spec page wasn't a complete contract. The first revision of the prompt produced 200-class responses that the Java client refused to parse, because the Java reference serializer emits every declared field by reflection. The spec page only documented the "interesting" ones. Hand-aligning the prompt against the actual Java model classes was a step we'd love to skip in future.
Gemini's Run
We handed the prompt to Gemini and asked it to produce the server from scratch. The
first build started, the first pytest failed, and over a series of
follow-up turns we worked the issues down. Each fix was specific and structural — not
a sign that the model misunderstood the protocol, but a sign that the long tail of
"where does this file live" and "what does pytest see as the cwd"
still requires nudging. Once those were resolved, the server happily round-tripped a
live chat session with the open-source Java reference client.
Concise Summary of the SecChats API Architecture (Updated)
We built a zero-trust, high-performance messaging backend using FastAPI + Async SQLite. Here is how the final, specification-compliant components work:
Zero-Trust Security: Cleartext authTokens are never
logged or stored. The server converts them into HMAC-SHA256 hashes immediately and
uses hmac.compare_digest to prevent timing attacks. Display names are
hardcoded to empty strings (""), keeping user identities entirely
inside encrypted blobs.
Per-Recipient Queuing: Direct messages, friend requests, and
invites are queued under the receiver's target identifier
(to_principal). Group messages are queued directly under the
groupId to avoid duplicating payloads across group members.
Lock-Free Real-Time Events (/longpoll): Instead of
wasteful sleep-loops, the server uses an asynchronous EventRegistry.
When a user posts a message to a group, any client waiting on /longpoll
for that group is instantly awakened via asyncio.Event within
milliseconds, timing out cleanly after 25 seconds if silent.
Token-Bucket Middleware: An in-memory sliding token bucket
restricts every IP address to 30 requests per 5 seconds, returning a plain text
429 Rate limited. when exceeded.
Key Path & Code Fixes Implemented
Absolute Path Resolution (schema.sql): Changed the
initialization code to find schema.sql using an absolute file-system
path relative to store.py. This fixed the initial crash where
pytest ran from a different working directory and couldn't locate the
SQL DDL file.
Absolute Path Resolution (db.sqlite): Forced the
SQLite database file path to anchor itself to the project directory absolutely.
This prevented tests and the runtime application from accidentally reading/writing
two separate database files in different temp directories.
On-Demand Schema Bootstrap: Added a fallback mechanism inside the
database connection factory (get_connection). If the database file is
missing or empty when a request hits the server (especially during rapid test
execution setups), it automatically bootstraps the database schema before trying
to run queries.
Verbatim Spec Matching for /postmsg (toId
Alignment): Corrected the validation schema and route behavior for the
/postmsg endpoint to use toId instead of
toUserId, bringing it into absolute conformance with the exact JSON
wire payload specification.
Claude's Run
We then handed the same prompt to Claude. The implementation came out in one shot: the
file tree, the Pydantic models, the SQLite store, the async event registry for
/longpoll, the token-bucket middleware, and a pytest file
covering one happy path per endpoint plus the long-poll wake-up test. pytest -q
reported 10/10 passing in 5.48 seconds on the first run, and a quick
sqlite3 db.sqlite '.dump' confirmed the zero-trust invariant: no
plaintext tokens, no display names, no plaintext message bodies anywhere on disk.
The interesting work after that point lived not in the server but in the integration
gap.
Concise Summary of the SecChats API Architecture
Same architecture as the Gemini build above, since both started from the same prompt. The interesting deltas are in how strictly the implementation pins itself to the wire format and in what showed up only once the server met a real client.
Pydantic v2 with lenient parse, strict emit:
model_config = ConfigDict(extra="ignore") means a client can send
forward-compat fields without 422-ing, while declared field types still reject
malformed payloads. Default values (str = "",
Optional[str] = None, int = 0) plus
model_dump_json() emit every declared field on every response —
null and 0 included — matching the Java reflection serializer byte for byte.
TOCTOU-free /longpoll: The handler subscribes to the
EventRegistry before querying the database for already-pending
messages. Without this, a message arriving between the empty-queue check and the
await would be missed for the full 25-second window.
Per-recipient queuing on opaque channel ids: The event registry is
keyed on string ids without caring whether the id is a user (DM) or a group (group
message). /postmsg and /postgroup both call
events.notify(toId); the long-poll handler subscribes to every channel
the client cares about. One primitive, both code paths.
Key Path & Code Fixes Implemented
First-Pass Test Pass: The initial implementation passed all ten tests — including the long-poll wake-up race — in 5.48 seconds on the first run. No DDL-path bug, no DB-path drift between test runs and the live server. The fixes below are observability and integration affordances rather than bug fixes against broken behaviour.
Verifying the Zero-Trust Invariant in CI: Added a
"definition of done" check that runs a real register → postmsg sequence and
greps the resulting db.sqlite dump for the cleartext token and
message bytes. Catches any future regression that would silently leak plaintext
to disk.
Disambiguating 429s in the log (not the wire): The spec returns
429 "Rate limited." for both rate-limit-exceeded and auth-failure,
making the two indistinguishable to anyone reading their server log. Added
server-side WARNING auth_fail: ... and WARNING rate_limit:
ip=... lines that name the actual reason without changing the wire response.
Diagnostic Body Middleware (opt-in): A debug middleware behind
SECCHATS_DEBUG_BODY=1 dumps the headers and first 300 bytes of every
incoming request body before FastAPI hands it to Pydantic. This is what revealed
the Java HttpClient's HTTP/2 upgrade attempt — the Java client was
sending Upgrade: h2c, HTTP2-Settings: ... alongside the body, and
uvicorn was silently dropping the body during the failed upgrade. The fix lived
in the client (pin HttpClient.Version.HTTP_1_1), but the diagnosis
came from the server side.
Per-client cursor fix in the Java /longpoll caller:
The reference Java client's PollGroups constructor computed its
lastMessageTimestamp as the max of group timestamps only, ignoring
user.lastMessageTimestamp. Once a DM had been consumed by
/receive, the user-side cursor advanced but the long-poll cursor
stayed at 0, so /longpoll kept finding the already-consumed DM and
returning immediately. Setting the synthetic "user-as-group" entry's
timestamp from user.lastMessageTimestamp aligned both cursors and
restored the spec'd blocking behaviour.
State of the Art: Three Conclusions
We started this experiment to find the wall. We found three:
1. Spec → server is largely solved. Both models produced working backends.
Given a tight prompt and a runnable rubric, both Gemini and Claude produced full FastAPI servers from a JSON spec — complete with zero-trust storage, async event wake-ups for long-poll, token-bucket rate limiting, and a passing test suite. The core protocol layer is genuinely one-shot today. The Java reference client, written in 2024 against the original Java server, talked to both Python rewrites without a single client-side change to its protocol logic. That's the part of the wall that has clearly moved.
2. Wire-format fidelity still requires a human-verified contract.
The published spec page on secchats.com/api.html was not, in fact, the
contract — it was an abbreviated summary of the contract. The actual contract
was "whatever the Java ChatSerializer emits by reflection from the
declared model fields". The first version of the prompt produced 200-class responses
that the Java client refused to parse because half the fields were missing. Fixing
this needed a human (or a model with read access to the reference codebase) to
explicitly audit SendMessage.java, FriendRequest.java,
User.java etc. against the published spec, and to write down the
"always-emit every declared field" rule the spec page elided. We've since corrected
the spec page itself, but the meta-problem — specs being silently incomplete —
will keep recurring until the model can fetch the reference implementation and audit
it without being asked.
3. The remaining bugs live in integration, not in the spec.
Both server runs were eventually unblocked by fixes that the spec said nothing about.
Gemini's blockers were filesystem layout: where schema.sql lives, where
db.sqlite resolves to when pytest changes the working
directory. Claude's blockers were transport quirks the spec doesn't and can't cover:
Java HttpClient's default attempt to upgrade plain HTTP to h2c, and the
Java reference client's per-cursor logic for /longpoll not aligning
across DM and group state. Neither category is going to be fixed by a better prompt —
they're emergent in the round-trip and require either a human in the loop or an
agent that can run real clients against the candidate server and observe what fails.
Net: a single prompt + a single model can produce a working server today, in one shot, from a published JSON spec. What still requires a human is the gap between spec and contract, and the gap between passes its own tests and actually round-trips with a real client. Both are narrower than they were even six months ago. Neither is closed yet. That's where the frontier sits.
Reference Java client: github.com/Secchats-tech/ChatJavaUI. API spec: secchats.com/api.html.