I taught Claude to read my Substack — here's the gateway behind it
Five repos, one contract, and what vibe coding done well actually means
That little exchange is the whole demo. I ask Claude about one of my own Substack notes, and it answers - not by scraping, not by guessing, but by calling a tool I wrote and getting back a typed response. The model is doing what models do; the interesting part is what sits underneath, where my publication is reachable as a real API instead of a screen to read.
There are three moving parts in that GIF, and each one has a job. Claude is the agent - the thing that decides which tool to call and what to do with the result. The Model Context Protocol is the transport between Claude and my service: a small, opinionated way for an LLM to talk to tools defined somewhere else. And the service itself is a Python REST gateway that speaks Substack on one side and a clean contract on the other, with an MCP surface bolted on as a sibling transport rather than a translator. The agent surface and the workflow surface - MCP for Claude, REST for everything else, including my n8n nodes - share one service layer in substack-gateway-oss.
Lately, I pushed the deprecation notices on two repositories I had been quietly maintaining for nearly a year: substack-api, a reverse-engineered TypeScript client, and the original n8n-nodes-substack, the community node that imported it. They were the first two pieces of this story, and they are both done. What replaced them is the gateway you watched Claude talk to.
This post is not really about Claude. It is about the boring middle layer that made the moment in the GIF cheap. I want to walk through how I got from a TypeScript wrapper that I patched by hand more than a hundred times to a contract-first gateway where REST and MCP fall out of the same service code, and why the order in which I did the work mattered more than any single decision inside it. The agent moment looks easy. It only looks easy because the parts underneath were done in the right order.
Why I built any of this
The honest origin story is small and unromantic. I write on Substack, and I wanted my own workflow around it - the kind of small automations I had been running for years on other platforms. Notes I could draft from a script. A way to restack things from an inbox. The ability to grep my own backlog. None of it was novel. It only mattered because it was mine, and because the friction of doing it by hand was annoying enough to fix.
The first place I tried to wire this up was n8n. n8n is a workflow tool I already use for other personal automations - webhooks, schedulers, the usual glue. If there had been an official Substack integration, I would have used it, written a few flows, and moved on with my life. There was not. There still is not, in any documented form.
That is the structural constraint behind everything that follows: Substack does not publish a public API. There is no spec, no terms-of-service-covered endpoint surface, no client library. There is only the network traffic the website and the mobile app make, which a determined reader can study and reproduce - the reader feed, the comment/feed endpoint that publishes notes, the profile endpoints that back the writer dashboards. Anything I wanted to automate had to ride those private endpoints, and any wrapper I wrote would, by definition, be reverse-engineered. There is no clean way around that. There is only choosing how much of the mess to absorb in one place.
I was not trying to build a SaaS. I was not trying to publish a client for the world. I wanted a way to drive my own publication from workflows I already trusted, and eventually from an agent, without writing the same HTTP-level glue twice. The first version of that was the most obvious move: build a small, typed client in the language n8n nodes are written in, and then wrap it. That worked, in the way first moves usually work - it solved the problem in front of me and quietly created the next one.
substack-api, the reverse-engineered TypeScript client
The first repository in this story is substack-api, started in June 2025. I picked TypeScript because the eventual consumer was an n8n community node, and n8n nodes are TypeScript end to end. Sharing a language with the consumer felt like a small win at the time. The code was tidy: a SubstackClient at the root, domain types for OwnProfile, Profile, Note, Post, and Comment, and a fluent NoteBuilder for composing notes. It compiled clean, it had a rollup build, it had jest tests. Inside the package, the discipline was decent.
The moment you crossed the package boundary in either direction, though, it was a different story. On the way down, every method ended at an undocumented Substack endpoint. The reader feed, the comment/feed endpoint, the profile endpoints - they all have stable enough shapes to model, but none of them have a contract anyone signed. On the way up, callers got typed domain objects, but those objects had been shaped by what the wire format happened to return. There was no layer in the middle insulating the domain from the transport.
The clearest example was publishing a note. NoteBuilder.publish() looks like a polite method on a fluent builder, but underneath it is doing exactly what you would do at a terminal with curl.
async publish(): Promise<PublishNoteResponse> {
const rawResponse = await this.substackClient.post<unknown>(
'/comment/feed/',
this.toNoteRequest()
)
return decodeOrThrow(PublishNoteResponseCodec, rawResponse, 'Publish note response')
}That POST /comment/feed/ is the load-bearing line. The request shape was hand-rolled. The response shape was decoded against a hand-written codec. Both were inferred by watching the website do the same thing in a browser. When Substack changed any of it - a renamed field, a new required header, a tightened validator - the only place to feel that change was inside this method, and the only way to find out was to publish a note and watch it fail.
Over the lifetime of the client, the repository ended up with 758 total commits, 126 of which I tagged as fixes - about seventeen percent of all commits were patches to keep the wire format honest. That number matches my own recollection of the era: more than a hundred manual fixes, most of them small, almost all of them reactive. It worked. I shipped notes, I read posts, I powered the early version of the n8n node. But the trade had already been made and I had not really seen it yet. By choosing TypeScript I had bought language continuity with the consumer; later I would have to spend that back when I rewrote the gateway in Python. And by reverse-engineering directly into a domain model, I had given the upstream wire format a permanent seat at the table inside my own code.
n8n-nodes-substack, the old node that imported the client
The second repository was the n8n community node, and it shipped on the same day as the client depending on substack-api from commit one. From the outside that pairing looked sensible. Two repositories, one boundary, no duplication. From the inside it was the second half of the same building.
The node's structure was operation-per-file: Note.operations.ts, Post.operations.ts, and so on. Every one of those files began the same way.
import { SubstackClient } from 'substack-api';
async function get(
executeFunctions: IExecuteFunctions,
client: SubstackClient,
publicationAddress: string,
itemIndex: number,
): Promise<IStandardResponse> {
try {
const limitParam = executeFunctions.getNodeParameter('limit', itemIndex, '');
const limit = OperationUtils.parseLimit(limitParam);
const ownProfile = await client.ownProfile();
const notesIterable = await ownProfile.notes();
const results = await OperationUtils.executeAsyncIterable(
notesIterable, limit, DataFormatters.formatNote, publicationAddress,
);
return { success: true, data: results, metadata: { status: 'success' } };
} catch (error) {
return SubstackUtils.formatErrorResponse({ message: error.message, node: executeFunctions.getNode(), itemIndex });
}
}import { SubstackClient } from 'substack-api'. There was no adapter, no DTO layer, no internal interface. The node code held a SubstackClient reference, called its methods, and handed the resulting domain objects straight to n8n's data formatters.
The repository accumulated 456 commits over its life. Much of that churn was not new features. It was the node catching up to the client catching up to Substack. The shared release rhythm I had thought of as a strength turned out to be the thing that made the cost compound: every Substack-side change rippled through substack-api, and every substack-api patch rippled through the node. I had traded the friction of a stable internal seam for speed of delivery, and the bill on that trade came in the form of a steady drip of evenings spent fixing the same shape of problem in two repositories at once.
The double-maintenance trap
The pain section is the easiest to write because the pain was concrete. By late 2025 I had a pattern: a Substack-side change would slip out, something in my workflows or my own publishing flow would fail quietly, I would track it back to a field rename or a new required header, and then I would spend an hour patching substack-api and another half hour patching the old node so the n8n side picked up the fix. Sometimes the patch broke an unrelated path - a field added for notes shifted a decoder used by posts, or a tightened type narrowed a method the comments code still depended on. The seventeen percent fix-tagged commit rate in substack-api is the receipt for that era. More than a hundred manual fixes, each one small, each one tied to something I could not see from my side.
The result was that every fix had a blast radius bigger than its cause. A change to one publishing endpoint could move output rows in three n8n nodes that did not touch publishing. A response-shape adjustment in the reader feed could shift comment-side decoders because they happened to share a codec. None of this was anyone's fault - it is what happens when the seam between "what the upstream does" and "what my system does" is not drawn anywhere. The two repositories were not really two layers; they were two halves of one layer pretending to be modular.
If I wanted to stop paying the double-maintenance tax, I had to insert a stable surface between the consumers and Substack - one place that absorbed the upstream churn and presented a shape my workflows and any future agent could rely on. The node could not be the contract, because the node was a consumer. The client could not be the contract, because the client was the part that broke. The contract had to be a new thing in the middle.
That insertion is the pivot. Everything below is what happens when you take it seriously instead of treating it as a wrapper exercise.
The pivot — vibe coding done well
I want to be careful here, because the phrase "vibe coding" has become a marketing word for several different things at once, and most of them are not what I mean. What I mean is narrower and more boring. When the implementation is going to be written by an agent and refined by a human, the order of the work has to invert. Architecture comes first. Contract comes second. Tests come third. Code comes last. The discipline is not in the tool the code gets typed into; the discipline is in deciding what gets typed before any of it is typed at all.
Architecture first means choosing the boundaries before choosing the libraries. Before I wrote a line of the new gateway, I had decided that there would be one Python service with two transports, that those transports would share a service layer. Once the boundaries are drawn, the implementation has somewhere to land. Once they are not, every line of code makes another small decision about where the seams go, and the seams end up scattered.
Contract first means defining the shapes the outside world will see before the internals know how to produce them. In the gateway, that meant Pydantic schemas in models/schemas.py doing double duty: they are the request and response types for the REST routes, and they are also the source of truth for the OpenAPI document that FastAPI generates at runtime. There is no standalone openapi.yaml in the repository. I considered writing one, the way the OpenAPI tradition suggests, and I decided against it. A hand-written spec file is a second source of truth that drifts; a generated one is a faithful projection of the code with no extra ceremony. The cost of that trade is that I cannot wave a spec file at a tool that wants one before the service runs. The benefit is that the Pydantic models are the spec, full stop, and there is exactly one place to look when a shape is in doubt.
Test first, in this project, meant Gherkin. The repository carries more than eighteen .feature files under features/api/ and features/mcp/, executed by behave, written in plain Given / When / Then against scenarios I wanted the gateway to honor. They are not unit tests dressed up. They are the executable form of the contract: the gateway is allowed to do these things, in these ways, with these failure modes, and anything else is out of scope until a new scenario says otherwise. BDD has more ceremony per scenario than a plain pytest file, and I traded that ceremony for readable specs that describe behavior in the same words I would use to talk about it.
The first-hour commits on the gateway tell the same story. Before there was a working endpoint, there was uv, ruff, ty, and CI. The linters and the type checker and the formatter were not retrofits; they were part of the architecture in the same way the directory layout was. The point was not that the tooling is special. The point is that quality gates installed late are negotiable and quality gates installed first are not. By the time any implementation arrived, the build was already failing on anything sloppy, and there was nowhere to hide a quick fix.
The order matters because of who is writing the code. With architecture, contract, and tests in place, the implementation step is constrained on all sides: the shapes are fixed, the scenarios are written, the directory layout is decided. An agent given that context produces code that fits a slot rather than code that decides what the slot should be. With those things missing, the same agent produces something that compiles, passes whatever tests exist, and quietly invents structure that nobody owns.
The thesis is not "let the model write the code". The thesis is "decide the boundaries, the contract, and the gates yourself, and then it is fine to let the model write the code". The agent is allowed to do the typing. The human keeps the decisions about shape.
That order is what made the rewrite below clean. If I had started with implementation and tried to retrofit a contract on top, this post would be a very different story, and probably one that ended with another round of patches on the old client.
substack-gateway-oss, born REST+MCP on Day 0
The stack inside OSS is intentionally narrow: Python 3.10 and up, uv as the package manager, FastAPI for the HTTP surface, FastMCP for the agent surface, Pydantic for the schemas, behave for the scenarios, pytest underneath, ruff and ty as gates, Vercel for hosting. The directory layout falls out of the boundaries the architecture step decided on. There is an api/v1/ directory holding the REST routes. There is an mcp/app.py holding the MCP tool registrations. They both depend on a single services/ package - the place where Substack-specific logic actually lives - and on models/schemas.py, which defines every shape that crosses any boundary. The transports are siblings, not parent and child. Neither one is allowed to own the domain.
The OSS gateway is live. It is deployed to Vercel at substack-gateway.vercel.app, with the REST surface under /api/v1, the auto-generated OpenAPI documentation at /api/docs, and the MCP server at /mcp. The OpenAPI page is the spec the Pydantic schemas project; the MCP endpoint is the one Claude talks to in the demo at the top of this post. Everything in the walkthrough below maps to a real route you can curl.
The Notes endpoint is the cleanest place to see the contract walk. The Pydantic-shaped REST route is small enough to read in one pass.
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Path
from gateway_oss.api.deps import get_notes_service
from gateway_oss.models.schemas import (CreateNoteRequest, CreateNoteResponse, NoteResponse)
from gateway_oss.services.notes import NotesService
router = APIRouter(tags=["notes"])
@router.get("/notes/{note_id}", response_model=NoteResponse)
async def get_note(note_id: Annotated[int, Path(gt=0)], service: Annotated[NotesService, Depends(get_notes_service)]) -> NoteResponse:
note = await service.get_note_by_id(note_id)
return NoteResponse.from_substack(note)
@router.delete("/notes/{note_id}", status_code=204)
async def delete_note(note_id: Annotated[int, Path(gt=0)], service: Annotated[NotesService, Depends(get_notes_service)]) -> None:
await service.delete_note(note_id)
@router.post("/notes", response_model=CreateNoteResponse, status_code=201)
async def create_note(body: CreateNoteRequest, service: Annotated[NotesService, Depends(get_notes_service)]) -> CreateNoteResponse:
try:
note = await service.create_note(body.content, attachment=body.attachment)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return CreateNoteResponse.from_substack(note)There are three things worth noticing in that snippet. The handlers ask FastAPI to inject NotesService rather than constructing it themselves, so the route is a thin binding between an HTTP verb and a method call. The request and response types are imported from models/schemas.py; the route does not define shapes, it consumes them. And the error path is a single ValueError → HTTPException translation - the service raises in its own vocabulary, the route translates once at the boundary, the contract stays clean.
The Gherkin sitting next to it describes exactly what those routes are allowed to do.
Feature: Create note endpoint
Scenario: Successfully create a note from markdown
Given a valid gateway token "test-token"
And the Substack create-note endpoint returns the sample response
When I send POST /api/v1/notes with JSON body {"content": "Hello **world**."}
Then the response status code is 201
And the response field "id" is not null
Scenario: Empty content returns 400
Given a valid gateway token "test-token"
When I send POST /api/v1/notes with JSON body {"content": ""}
Then the response status code is 400
Scenario: Authentication failure returns 401
Given a valid gateway token "test-token"
And the Substack create-note endpoint returns status 401
When I send POST /api/v1/notes with JSON body {"content": "Hello world."}
Then the response status code is 401
Scenario: Missing x-gateway-token header returns 422
When I send POST /api/v1/notes with JSON body {"content": "Hello world."}
Then the response status code is 422Each scenario is one decision about acceptable behavior. The happy path returns 201 with a non-null id. Empty content is a client error, not a 500. An upstream 401 surfaces as a 401, not a generic failure. A missing x-gateway-token header is a 422 from FastAPI's own validation, before the service ever runs. The .feature file is not a description of the implementation; it is the spec the implementation is allowed to honor.
There are a few decisions visible in this section that are worth naming as trades rather than as facts. Choosing REST and MCP as siblings instead of treating MCP as an adapter over the REST gateway cost me a small amount of duplicated wiring at the transport level - two registration points instead of one. It bought a service layer that does not know which transport is calling it, which means neither transport can quietly corrupt the domain to fit its own ergonomics. Choosing Python over TypeScript broke language continuity with the n8n consumer and gave up the option of sharing a package; it bought FastAPI plus FastMCP plus Pydantic plus Behave, which together land the contract-first story almost for free.
⚖️ What if there’s no such thing as a “perfect architecture”?
Most system design decisions are trade-offs - not best practices. Every decision optimizes for something… and sacrifices something else.
💬 What’s a trade-off you struggled with?
The next section is the payoff for all of it.
MCP fell out of REST nearly for free
The reason MCP was cheap to add is that the service layer never knew which transport was calling it. NotesService does not have an HTTP Request object anywhere in its signature. It does not know about headers, status codes, or content types. It knows how to fetch a note, delete a note, and create a note, and it speaks in Pydantic models. That is exactly what an MCP tool needs.
_mcp = FastMCP("substack-gateway", auth=runtime.mcp_auth_provider)
@_mcp.tool(
description="Retrieve a single Substack note by its numeric ID.",
tags={"notes", "read"},
annotations=ToolAnnotations(
title="Get Note",
readOnlyHint=True,
idempotentHint=True,
),
)
async def get_note(note_id: int) -> dict[str, Any]:
async with (
_public_publication_client() as publication,
_public_substack_client() as substack,
):
note = await NotesService(publication, substack).get_note_by_id(note_id)
return NoteResponse.from_substack(note).model_dump(exclude_none=True)
mcp = _mcp.http_app(transport="streamable-http", path="/", stateless_http=True)The get_note tool constructs a NotesService with the same dependencies the REST route gets through FastAPI's injection, calls the same get_note_by_id method, and returns the same NoteResponse shape, serialized through model_dump instead of FastAPI's response model machinery. There was no second domain to design. There was no MCP-flavored variant of the Notes service.
That second decision is worth lingering on. The gateway never holds a Substack session. Credentials are passed per call: base64-encoded JSON containing the publication URL and the relevant cookies, sent as x-gateway-token over REST or as a token argument on the MCP tool call. The trade is ergonomic - the caller has to handle credentials on every request instead of authenticating once and forgetting about it. The benefit is that the service is stateless in the strong sense: there is no server-side session to leak, expire, or rotate; there is no shared in-memory state to invalidate; horizontal scaling is a vercel deploy away. For an agent surface, that property matters more than the convenience of stored credentials, because agents make a lot of small calls and the security story has to hold under each one.
Looking back at the section title - MCP fell out of REST nearly for free - the word doing real work in that sentence is "nearly". There was work to do. There were tool annotations to write, scenarios to add under features/mcp/, an auth provider to wire in. What there was not was a new domain, a new service layer, or a new shape of Note. That is what the contract-first work bought.
n8n-nodes-substack-new, talking only the contract
The rewritten n8n node started with a commit message I am still fond of: "feat: Substack API v2". It has accumulated 239 commits since then, which sounds like a lot until you remember it is the node and only the node - none of those commits are Substack patches, because the node no longer talks to Substack. It talks to the REST contract. Substack is somebody else's problem now, from this repository's point of view.
The internal structure is built around Effect and @effect/platform. There are five nodes - Substack Gateway, Following Feed, Profile Feed, Batch Feed, Randomizer - sitting on top of a runtime/ pipeline that walks every operation through six typed stages: decode the requested operation, read the n8n input, decode it into a command, build the HTTP request, execute it, decode the response. Each stage's input and output are types the next stage consumes. The pipeline is the schema; the schema is the pipeline.
import * as HttpClient from '@effect/platform/HttpClient';
import * as ClientRequest from '@effect/platform/HttpClientRequest';
import * as ClientResponse from '@effect/platform/HttpClientResponse';
import { Effect, Match, pipe } from 'effect';
const makeRequest = (request: GatewayHttpRequest) =>
Match.value(request.method).pipe(
Match.when('GET', () => ClientRequest.get(request.url)),
Match.when('POST', () => ClientRequest.post(request.url)),
Match.when('PUT', () => ClientRequest.put(request.url)),
Match.when('DELETE', () => ClientRequest.del(request.url)),
Match.exhaustive,
);
export const executeGatewayRequest = (request: GatewayHttpRequest) =>
pipe(
makeRequest(request),
Effect.flatMap(HttpClient.execute),
Effect.mapError(toApiError('Gateway request failed')),
Effect.flatMap(ClientResponse.filterStatusOk),
Effect.flatMap((response) =>
request.responseMode === 'empty'
? Effect.succeed(request.emptyResponseBody ?? {})
: response.json,
),
);The execute step is small but worth reading. The verb selection is a Match over the method, exhaustive at the type level - if I add a new HTTP verb to the gateway, this match stops compiling until I handle it. The execution itself is a single HttpClient.execute call, with error translation to a domain-specific ApiError and status filtering before any decoding. The decoder branches on whether the request expects an empty response or a JSON body. Crucially, nothing in this file knows anything about Substack. It knows about URLs, methods, status codes, and the gateway's response envelopes. The contract has reached all the way into the node's transport layer, and the node has stopped knowing about anything below it.
The trade in choosing Effect was the steepest learning curve in this whole arc. It is not a library you pick up over a weekend. The compiler errors are precise and patient, and the runtime gives you typed pipelines, structured errors, and resource safety in exchange for taking its model seriously. I traded onboarding cost for a transport layer that mirrors the gateway's contract instead of leaking Substack's wire format into n8n nodes - and the cost of that trade is paid once, by me, while the benefit shows up every time the gateway adds an endpoint and the node picks it up without learning anything new.
What the discipline bought
Recently I pushed deprecation notices on substack-api and the old n8n-nodes-substack. The TypeScript client and the node that imported it - the two halves of the original double-maintenance trap - are both retired. The migration was clean. No caller was stranded. Everything I had been driving from the old node now runs through the rewritten one against the gateway, and the gateway's MCP surface gave me a second consumer for free.
Architecture-first, contract-first, tests-first work is supposed to make migrations cheap, and the only way to know whether it actually did is to do a migration. This one was cheap.
The agent demo at the top of this post is the other receipt. Yes, Claude is reading my Substack through the gateway, and yes, that is a small wonder. It is the kind of moment that gets a GIF and an opening line. But the wonder is not the model. The wonder is that I did not have to build anything Claude-shaped to make it happen. The MCP surface is the same NotesService the REST routes call, exposed through FastMCP as a sibling transport, gated to read-only, credentialed per call. The model decides which tool to invoke. The boring layer underneath decides what the tools mean. The boring layer is the post.
There is a PRO sibling repository I have been deliberately quiet about throughout this write-up. substack-gateway-pro extends the OSS gateway with the parts that need real authentication - write paths and personal-data reads exposed as MCP tools, sitting on top of the same service architecture, never storing Substack credentials on the server. The same Notes domain that anchored the walkthrough is the one PRO broadens. That is the entire teaser. I am not committing to a roadmap in this post and I am not publishing a feature matrix; the point of mentioning it is that the OSS/PRO split was a Day-0 architectural decision, and the discipline that paid off on the OSS side is what makes the PRO side tractable at all.
If there is one sentence I want to leave the reader with, it is this. Vibe coding done well does not mean the model writes the code while the human watches. It means the human owns the architecture, the contract, and the quality gates, and the model fills in the implementation that those three constraints have already shaped. When that order holds, the agent's code stays inside the boundaries the human drew, and a rewrite that touches five repositories can finish on a Tuesday with both originals deprecated by lunch. When the order does not hold, you get the first version of this story - a fluent TypeScript client, a node that imports it directly, a hundred small fixes, and a slow understanding that the seam you wanted was never drawn. The gateway is not the clever part. Having a contract is.
References
Model Context Protocol specification by Anthropic | modelcontextprotocol.io
Substack Gateway OSS by Jakub Slys | GitHub
Substack Node by Jakub Slys | GitHub
N8n by Jan Oberhauser | n8n.io
FastAPI by Sebastián Ramírez | fastapi.tiangolo.com
OpenAPI Initiative by Linux Foundation | openapis.org
Behave by Benno Rice | behave.readthedocs.io
FastMCP by Jeremiah Lowin | GitHub
New Substack Node by Jakub Slys | GitHub









