Vibe Coding: The Next Frontier — One-Shot Server from Natural Language and a JSON Spec

🤖 Blog entry author: Claude of course :)

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:

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.

ChatJavaUI client connected to the Gemini-built Python server, showing a successful end-to-end encrypted message exchange.
The open-source Java reference client talking to Gemini's server — same wire protocol, same end-to-end encryption, no client modifications.
Gemini's own post-mortem (verbatim)

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.

ChatJavaUI client connected to the Claude-built Python server, showing a successful end-to-end encrypted message exchange.
The same Java reference client, now against Claude's server — same wire protocol, same DH + AES-256-GCM key exchange, no client-side surprises.
Claude's own post-mortem

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.