Every B2B team I've worked with has the same graveyard: a folder of half-finished integration scripts, a dozen n8n workflows held together with webhooks, and a Slack channel where someone asks, every other week, "Did the HubSpot sync break again?"
Integrations are the unglamorous tax every software company pays. And in 2026, that tax has become a real business opportunity. The surge in specialized developer Micro SaaS isn't about building another general-purpose AI chatbot. It's about picking one painful, repetitive engineering job and automating it end to end.
Third-party API integration is one of the best candidates out there.
In this guide, we'll architect a platform that does something static automation tools can't: it reads API documentation on its own, generates working Node.js and Express.js connectors, tests them, and routes them to a human reviewer before anything touches production. We'll use LangGraph for long-running agent orchestration, Ollama for local LLM inference, PostgreSQL for durable state, Next.js with Shadcn UI for the review dashboard, Stripe for billing, and a self-healing GitHub Actions pipeline deploying to AWS.
This isn't a toy. It's the blueprint I'd use to ship a paid product.

Why Agentic Integrations Beat Static Webhooks
Let's be honest about what tools like n8n and Zapier do well: they're fantastic when the integration you need already exists as a node, and the API never changes. The trouble starts when either of those assumptions fails.
Here's where static workflows hurt:
- Missing connectors. Niche vertical SaaS tools (dental practice software, logistics APIs, regional payment providers) rarely have prebuilt nodes.
- API drift. A vendor renames a field, deprecates v2, or changes pagination from offset to cursor. Your webhook silently returns empty arrays.
- Opaque logic. Business-critical data flows live in a visual editor instead of version-controlled, testable code.
- No ownership of code. Customers can't export, audit, or extend what they've built.
An agentic approach flips the model. Instead of a human configuring HTTP steps, an agent:
- Ingests the vendor's docs (OpenAPI spec, Postman collection, or plain HTML).
- Builds a normalized model of endpoints, auth schemes, and rate limits.
- Generates a typed Express.js connector with retries and pagination.
- Writes and runs tests against a sandbox or recorded fixtures.
- Pauses for human approval.
- Deploys, monitors, and regenerates when the upstream API changes.
The output is real code that customers own. That's the core value proposition, and it's why buyers will pay for it.
The System Architecture at a Glance
Before writing a single line, let's map the moving pieces. The platform splits into four planes:
- Control plane (Next.js): Customer dashboard, review queue, billing, OAuth connection management.
- Agent plane (Python + LangGraph + Ollama): Long-running workflows that parse docs and generate code.
- Runtime plane (Node.js + Express): Executes approved connectors in isolated containers.
- Data plane (PostgreSQL + S3 + KMS): Integration state, agent checkpoints, artifacts, encrypted secrets.
flowchart LR
subgraph Control["Control Plane - Next.js"]
UI[Dashboard and Review Queue]
API[Route Handlers and Server Actions]
Stripe[Stripe Webhooks]
end
subgraph Agent["Agent Plane - Python"]
LG[LangGraph Workflow]
OL[Ollama Local LLMs]
SB[Sandbox Test Runner]
end
subgraph Runtime["Runtime Plane - Node.js"]
EX[Express Connector Gateway]
CN[Generated Connectors]
end
subgraph Data["Data Plane"]
PG[(PostgreSQL)]
S3[(S3 Artifacts)]
KMS[AWS KMS]
end
UI --> API
API -->|enqueue job| PG
LG -->|poll jobs and checkpoint| PG
LG --> OL
LG --> SB
LG -->|store code| S3
API -->|approve or reject| PG
EX --> CN
CN -->|decrypt tokens| KMS
EX --> PG
Stripe --> API
A key design decision: the Next.js app never talks to the LLM directly. It writes jobs and decisions into PostgreSQL. The agent workers pick up work, checkpoint progress, and surface results back through the same database. This decoupling means your web tier stays fast and cheap, while GPU-bound agent workers scale independently.
Designing the Agent Workflow with LangGraph
LangGraph is the right tool here because connector generation is a cyclic, stateful process, not a linear chain. Tests fail and need fixing. Humans reject code and leave feedback. Workflows sometimes wait for days.
Modeling the State
Everything starts with a typed state object. Think of it as the agent's working memory, which LangGraph persists at every step.
# agent/state.py
from typing import TypedDict, Literal, Optional
from pydantic import BaseModel
class Endpoint(BaseModel):
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
path: str
summary: str
params: list[dict]
request_schema: Optional[dict] = None
response_schema: Optional[dict] = None
class IntegrationState(TypedDict):
integration_id: str
tenant_id: str
docs_url: str
raw_docs: str
auth_scheme: Optional[dict]
endpoints: list[Endpoint]
generated_code: dict[str, str] # filename -> source
test_report: Optional[dict]
fix_attempts: int
review_status: Literal["pending", "approved", "rejected", "none"]
reviewer_feedback: Optional[str]Building the Graph
Now the workflow itself. Each node is a plain function that takes state and returns a partial update.
# agent/graph.py
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from agent.state import IntegrationState
from agent.nodes import (
fetch_docs, extract_spec, generate_connector,
run_tests, fix_code, deploy_connector,
)
MAX_FIX_ATTEMPTS = 3
def route_after_tests(state: IntegrationState) -> str:
report = state["test_report"] or {}
if report.get("passed"):
return "human_review"
if state["fix_attempts"] >= MAX_FIX_ATTEMPTS:
return "human_review" # let a human decide on a stubborn failure
return "fix_code"
def human_review(state: IntegrationState) -> Command:
# Pauses the graph and persists the checkpoint until the dashboard resumes it.
decision = interrupt({
"integration_id": state["integration_id"],
"files": list(state["generated_code"].keys()),
"test_report": state["test_report"],
})
if decision["action"] == "approve":
return Command(update={"review_status": "approved"}, goto="deploy_connector")
return Command(
update={
"review_status": "rejected",
"reviewer_feedback": decision.get("feedback", ""),
"fix_attempts": 0,
},
goto="generate_connector",
)
def build_graph():
g = StateGraph(IntegrationState)
g.add_node("fetch_docs", fetch_docs)
g.add_node("extract_spec", extract_spec)
g.add_node("generate_connector", generate_connector)
g.add_node("run_tests", run_tests)
g.add_node("fix_code", fix_code)
g.add_node("human_review", human_review)
g.add_node("deploy_connector", deploy_connector)
g.add_edge(START, "fetch_docs")
g.add_edge("fetch_docs", "extract_spec")
g.add_edge("extract_spec", "generate_connector")
g.add_edge("generate_connector", "run_tests")
g.add_conditional_edges("run_tests", route_after_tests)
g.add_edge("fix_code", "run_tests")
g.add_edge("deploy_connector", END)
return gHere's what that graph looks like visually:
stateDiagram-v2
[*] --> fetch_docs
fetch_docs --> extract_spec
extract_spec --> generate_connector
generate_connector --> run_tests
run_tests --> fix_code: tests failed and attempts remain
fix_code --> run_tests
run_tests --> human_review: passed or max attempts
human_review --> deploy_connector: approved
human_review --> generate_connector: rejected with feedback
deploy_connector --> [*]
Notice the two loops. The inner loop (run_tests to fix_code) is the agent healing its own mistakes. The outer loop (human_review to generate_connector) is a human steering the agent with feedback. Both are first-class citizens in the graph, not hacks bolted on afterwards.
Durable Checkpoints in PostgreSQL
The interrupt() call is only useful if the state survives restarts, deploys, and the reviewer going on vacation. LangGraph's Postgres checkpointer handles this.
# agent/worker.py
import os
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.types import Command
from agent.graph import build_graph
DB_URI = os.environ["AGENT_DATABASE_URL"]
def start_run(integration_id: str, tenant_id: str, docs_url: str):
with PostgresSaver.from_conn_string(DB_URI) as saver:
saver.setup() # idempotent: creates checkpoint tables
app = build_graph().compile(checkpointer=saver)
config = {"configurable": {"thread_id": integration_id}}
app.invoke(
{
"integration_id": integration_id,
"tenant_id": tenant_id,
"docs_url": docs_url,
"fix_attempts": 0,
"review_status": "none",
"generated_code": {},
"endpoints": [],
},
config,
)
def resume_run(integration_id: str, decision: dict):
with PostgresSaver.from_conn_string(DB_URI) as saver:
app = build_graph().compile(checkpointer=saver)
config = {"configurable": {"thread_id": integration_id}}
app.invoke(Command(resume=decision), config)Using the integration_id as the thread_id is a small trick that pays off: every integration has exactly one durable timeline you can inspect, replay, or fork for debugging.
Parsing API Docs with Local LLMs via Ollama
Why go local? Because your customers are B2B. Their API keys, internal endpoint names, and partner documentation are sensitive. Being able to say "your specs never leave our infrastructure" closes deals, especially in healthcare, fintech, and the EU.
Prefer Structure Over Scraping
The single most important rule: don't feed raw HTML to the model if you can avoid it. Use a tiered strategy:
- Look for an OpenAPI or Swagger spec (
/openapi.json,/swagger.json, linked in docs). - Look for a Postman collection.
- Only then fall back to HTML, cleaned into Markdown and chunked by heading.
When you do need the LLM, force structured output with a schema.
# agent/nodes/extract_spec.py
from langchain_ollama import ChatOllama
from pydantic import BaseModel
from agent.state import Endpoint, IntegrationState
class ExtractedSpec(BaseModel):
base_url: str
auth_scheme: dict
endpoints: list[Endpoint]
llm = ChatOllama(
model="qwen2.5-coder:32b",
base_url="http://ollama:11434",
temperature=0,
num_ctx=32768,
)
structured_llm = llm.with_structured_output(ExtractedSpec)
SYSTEM = """You are an API analyst. Extract every documented REST endpoint,
its HTTP method, path, parameters, and JSON schemas. Identify the auth scheme
(oauth2, api_key, basic, bearer). Never invent endpoints that are not in the text."""
def extract_spec(state: IntegrationState) -> dict:
spec: ExtractedSpec = structured_llm.invoke([
("system", SYSTEM),
("user", state["raw_docs"][:120_000]),
])
return {
"auth_scheme": spec.auth_scheme,
"endpoints": spec.endpoints,
}A practical note on model choice: pick a strong open-weight coding model for generation and a smaller, faster instruct model for classification tasks like "is this page an API reference?". Mixing models by task keeps GPU costs down considerably.
Generating Node.js and Express.js Connectors
The generation node turns the normalized spec into a small Express router. The goal isn't to let the model freestyle; it's to have it fill in a well-defined template that your platform controls.
The Connector Contract
Every generated connector must implement the same interface. This is what makes them testable, deployable, and swappable.
// runtime/src/types/connector.ts
export interface CredentialProvider {
getAccessToken(connectionId: string): Promise<string>;
}
export interface ConnectorContext {
connectionId: string;
credentials: CredentialProvider;
logger: { info: (msg: string, meta?: object) => void; error: (msg: string, meta?: object) => void };
}
export interface ConnectorModule {
name: string;
version: string;
createRouter(ctx: ConnectorContext): import("express").Router;
}A Real Generated Connector
Here's a trimmed example of what the agent produces for a fictional CRM API. Notice the retries, cursor pagination, and runtime credential injection; the model never sees a real token.
// generated/acme-crm/index.ts
import { Router } from "express";
import { z } from "zod";
import type { ConnectorModule, ConnectorContext } from "../../types/connector";
const BASE_URL = "https://api.acme-crm.example/v3";
const ListContactsQuery = z.object({
limit: z.coerce.number().min(1).max(100).default(50),
cursor: z.string().optional(),
});
async function fetchWithRetry(url: string, init: RequestInit, retries = 3): Promise<Response> {
for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429 && res.status < 500) return res;
const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error(`Upstream failed after ${retries} retries: ${url}`);
}
const connector: ConnectorModule = {
name: "acme-crm",
version: "1.0.0",
createRouter(ctx: ConnectorContext) {
const router = Router();
router.get("/contacts", async (req, res, next) => {
try {
const query = ListContactsQuery.parse(req.query);
const token = await ctx.credentials.getAccessToken(ctx.connectionId);
const url = new URL(`${BASE_URL}/contacts`);
url.searchParams.set("limit", String(query.limit));
if (query.cursor) url.searchParams.set("cursor", query.cursor);
const upstream = await fetchWithRetry(url.toString(), {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!upstream.ok) {
ctx.logger.error("acme-crm list contacts failed", { status: upstream.status });
return res.status(upstream.status).json({ error: "upstream_error" });
}
const body = await upstream.json();
res.json({ data: body.items, nextCursor: body.next_cursor ?? null });
} catch (err) {
next(err);
}
});
return router;
},
};
export default connector;Testing in a Sandbox
The run_tests node writes the generated files into an ephemeral Docker container, installs pinned dependencies, and runs a generated Vitest suite against recorded HTTP fixtures (or the vendor's sandbox when one exists). Key constraints for the sandbox:
- No outbound network except an allowlist of the vendor's sandbox domain.
- CPU and memory limits plus a hard timeout (60 to 120 seconds is plenty).
- Read-only root filesystem, with a single writable temp directory.
- Static analysis first: run ESLint with a security plugin and block
eval,child_process, and dynamicrequirebefore execution.
The test report (pass or fail, stack traces, lint findings) flows back into the graph state, which is exactly what the fix_code node needs to repair its own output.
Building the Human-in-the-Loop Review Queue
This is where trust is earned. Engineering leaders won't let AI-generated code near production unless they can see it, diff it, and sign off on it. The review queue isn't a nice-to-have; it's the product.

The Data Model
-- db/migrations/001_integrations.sql
CREATE TYPE integration_status AS ENUM (
'queued', 'generating', 'testing', 'awaiting_review',
'approved', 'rejected', 'deployed', 'failed'
);
CREATE TABLE integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
docs_url TEXT NOT NULL,
status integration_status NOT NULL DEFAULT 'queued',
current_version INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE integration_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
version INT NOT NULL,
artifact_s3_key TEXT NOT NULL,
test_report JSONB,
reviewed_by UUID REFERENCES users(id),
review_decision TEXT CHECK (review_decision IN ('approve', 'reject')),
review_feedback TEXT,
reviewed_at TIMESTAMPTZ,
UNIQUE (integration_id, version)
);
CREATE INDEX idx_integrations_review
ON integrations (tenant_id, status)
WHERE status = 'awaiting_review';
-- Row-level security keeps tenants isolated even if app code has a bug
ALTER TABLE integrations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON integrations
USING (tenant_id = current_setting('app.tenant_id')::uuid);The integration_versions table is your audit trail. Every generated artifact, every reviewer, every decision is recorded. When a SOC 2 auditor asks "who approved this code?", you have an answer in one query.
The Server Action for Approvals
With the Next.js App Router, approvals are a typed server action. The action validates the session, checks the role, records the decision, and notifies the agent worker.
// app/(dashboard)/reviews/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { enqueueResume } from "@/lib/queue";
const ReviewSchema = z.object({
integrationId: z.string().uuid(),
version: z.number().int().positive(),
action: z.enum(["approve", "reject"]),
feedback: z.string().max(4000).optional(),
});
export async function submitReview(input: z.infer<typeof ReviewSchema>) {
const session = await auth();
if (!session?.user || !session.user.roles.includes("reviewer")) {
throw new Error("Forbidden");
}
const data = ReviewSchema.parse(input);
await db.transaction(async (tx) => {
await tx.query(
`UPDATE integration_versions
SET review_decision = $1, review_feedback = $2,
reviewed_by = $3, reviewed_at = now()
WHERE integration_id = $4 AND version = $5 AND review_decision IS NULL`,
[data.action, data.feedback ?? null, session.user.id, data.integrationId, data.version],
);
await tx.query(
`UPDATE integrations SET status = $1, updated_at = now() WHERE id = $2`,
[data.action === "approve" ? "approved" : "rejected", data.integrationId],
);
});
await enqueueResume(data.integrationId, {
action: data.action,
feedback: data.feedback,
});
revalidatePath("/reviews");
}The review_decision IS NULL guard is subtle but important. It makes the approval idempotent, so a double-click or two reviewers acting at once can't resume the graph twice.
The Review UI with Shadcn
A good reviewer screen shows three things side by side: the source docs excerpt, the generated code with syntax highlighting, and the test report. Here's the action bar component:
// app/(dashboard)/reviews/[id]/review-actions.tsx
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { submitReview } from "../actions";
type Props = { integrationId: string; version: number; testsPassed: boolean };
export function ReviewActions({ integrationId, version, testsPassed }: Props) {
const [feedback, setFeedback] = useState("");
const [pending, startTransition] = useTransition();
const decide = (action: "approve" | "reject") =>
startTransition(async () => {
try {
await submitReview({ integrationId, version, action, feedback });
toast.success(action === "approve" ? "Connector approved" : "Sent back to agent");
} catch {
toast.error("Could not submit review");
}
});
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<Badge variant={testsPassed ? "default" : "destructive"}>
{testsPassed ? "All tests passing" : "Tests failing"}
</Badge>
<Textarea
placeholder="Feedback for the agent (required when rejecting)"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
/>
<div className="flex gap-2">
<Button disabled={pending || !testsPassed} onClick={() => decide("approve")}>
Approve and deploy
</Button>
<Button
variant="outline"
disabled={pending || feedback.trim().length === 0}
onClick={() => decide("reject")}
>
Request changes
</Button>
</div>
</div>
);
}Requiring feedback on rejection isn't just UX polish. That text goes straight back into the agent's prompt on the next generation pass, so reviewers are actively training the output for their own team.
Secure OAuth: Two Layers You Can't Skip
There are actually two distinct OAuth concerns in this product, and mixing them up is a common source of security bugs.
- User authentication to your SaaS. Use Auth.js (or a managed provider) with GitHub and Google sign-in, plus SSO via SAML/OIDC for enterprise plans.
- Customer connections to third-party APIs. Your platform acts as an OAuth client on behalf of the customer, and must store their access and refresh tokens.
For the second layer, use envelope encryption:
// lib/token-vault.ts
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from "@aws-sdk/client-kms";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const kms = new KMSClient({ region: process.env.AWS_REGION });
const KEY_ID = process.env.TOKEN_VAULT_KMS_KEY_ID!;
export async function encryptToken(plaintext: string) {
const { Plaintext, CiphertextBlob } = await kms.send(
new GenerateDataKeyCommand({ KeyId: KEY_ID, KeySpec: "AES_256" }),
);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", Buffer.from(Plaintext!), iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
ciphertext: enc.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
encryptedKey: Buffer.from(CiphertextBlob!).toString("base64"),
};
}
export async function decryptToken(rec: { ciphertext: string; iv: string; tag: string; encryptedKey: string }) {
const { Plaintext } = await kms.send(
new DecryptCommand({ CiphertextBlob: Buffer.from(rec.encryptedKey, "base64") }),
);
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(Plaintext!), Buffer.from(rec.iv, "base64"));
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(rec.ciphertext, "base64")),
decipher.final(),
]).toString("utf8");
}Always use PKCE and a signed state parameter in the authorization flow, refresh tokens in a background worker before they expire, and, most importantly, never place real tokens into an LLM prompt or a sandbox test run. The agent works with fixtures; production credentials only exist inside the runtime plane.
Monetizing with Stripe
Agent runs cost real GPU time, so pure flat-rate pricing will eventually hurt your margins. A hybrid model works well:
| Plan | Monthly Base | Included Active Connectors | Agent Generation Runs | Overage |
|---|---|---|---|---|
| Starter | $49 | 3 | 10 | Metered per run |
| Team | $199 | 15 | 60 | Metered per run |
| Enterprise | Custom | Unlimited | Custom | Contracted |
Use Stripe Checkout for subscriptions and Stripe's usage-based billing (meters and meter events) to report agent runs. The webhook handler is the source of truth for entitlements:
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Idempotency: ignore events we've already processed
const inserted = await db.query(
`INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id`,
[event.id, event.type],
);
if (inserted.rowCount === 0) return new Response("ok");
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await db.query(
`UPDATE tenants SET plan = $1, subscription_status = $2 WHERE stripe_customer_id = $3`,
[sub.items.data[0].price.lookup_key, sub.status, sub.customer],
);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await db.query(
`UPDATE tenants SET plan = 'free', subscription_status = 'canceled' WHERE stripe_customer_id = $1`,
[sub.customer],
);
break;
}
}
return new Response("ok");
}When an agent run completes, the Python worker reports a meter event:
# agent/billing.py
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def report_agent_run(stripe_customer_id: str, integration_id: str):
stripe.billing.MeterEvent.create(
event_name="agent_generation_run",
payload={"stripe_customer_id": stripe_customer_id, "value": "1"},
identifier=f"run-{integration_id}", # dedupe on retries
)Check entitlements before starting a run, not after. Nothing annoys a founder more than a month of unbilled GPU usage from a customer whose card failed in week one.
Containerization and Deployment on AWS
Each plane gets its own container image. Here's the agent worker Dockerfile:
# agent/Dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY agent ./agent
RUN useradd --create-home --uid 10001 agent
USER agent
CMD ["uv", "run", "python", "-m", "agent.worker"]And a local development compose file that mirrors production:
# docker-compose.yml
services:
postgres:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: integrations
ports: ["5432:5432"]
ollama:
image: ollama/ollama:latest
volumes: ["ollama:/root/.ollama"]
deploy:
resources:
reservations:
devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]
agent:
build: ./agent
environment:
AGENT_DATABASE_URL: postgresql://postgres:dev@postgres:5432/integrations
OLLAMA_URL: http://ollama:11434
depends_on: [postgres, ollama]
runtime:
build: ./runtime
ports: ["4000:4000"]
depends_on: [postgres]
web:
build: ./web
ports: ["3000:3000"]
depends_on: [postgres]
volumes:
ollama:On AWS, a sensible production topology looks like this:
- Web (Next.js): ECS Fargate behind an Application Load Balancer, or a managed Next.js host if you prefer.
- Agent workers: ECS on EC2 GPU instances (or EKS with a GPU node group), autoscaled on queue depth.
- Ollama: Co-located with agent workers or as a separate internal service on the GPU nodes.
- Runtime connectors: ECS Fargate tasks per tenant tier, with security groups restricting egress.
- Data: Amazon RDS for PostgreSQL (Multi-AZ), S3 for artifacts, KMS for the token vault, Secrets Manager for platform secrets.
Self-Healing CI/CD with GitHub Actions
"Self-healing" gets thrown around loosely, so let's be specific. In this platform it means two things:
- Pipeline self-healing: when a build or test fails, the pipeline gathers the logs, asks the agent to propose a fix, and opens a pull request for human review. It never pushes directly to
main. - Deployment self-healing: after deploying, the pipeline runs smoke tests and automatically rolls back to the previous task definition if they fail.

sequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant AG as Fix Agent
participant AWS as AWS ECS
Dev->>GH: Push to main
GH->>GH: Lint, test, build images
alt Tests fail
GH->>AG: Send failure logs
AG-->>GH: Proposed patch
GH->>Dev: Open fix PR for review
else Tests pass
GH->>AWS: Deploy new task definition
GH->>AWS: Run smoke tests
alt Smoke tests fail
GH->>AWS: Roll back to previous revision
GH->>Dev: Alert with incident summary
end
end
The workflow:
# .github/workflows/deploy.yml
name: build-test-deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm -r lint && pnpm -r test 2>&1 | tee test.log
- name: Upload failure logs
if: failure()
uses: actions/upload-artifact@v4
with: { name: test-log, path: test.log }
propose-fix:
needs: test
if: failure()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { name: test-log }
- name: Ask fix agent for a patch
run: |
curl -sf -X POST "$FIX_AGENT_URL/propose" \
-H "Authorization: Bearer $FIX_AGENT_TOKEN" \
-F "log=@test.log" -F "sha=$GITHUB_SHA" -o fix.patch
env:
FIX_AGENT_URL: ${{ secrets.FIX_AGENT_URL }}
FIX_AGENT_TOKEN: ${{ secrets.FIX_AGENT_TOKEN }}
- name: Open pull request
run: |
git checkout -b autofix/${GITHUB_SHA::7}
git apply fix.patch
git -c user.name="autofix-bot" -c user.email="bot@example.com" commit -am "fix: automated patch for ${GITHUB_SHA::7}"
git push origin HEAD
gh pr create --title "Automated fix for failing build" --body "Generated from CI logs. Review carefully." --label autofix
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Build and push images
run: ./scripts/build-and-push.sh
- name: Deploy and capture previous revision
id: deploy
run: echo "previous=$(./scripts/deploy.sh)" >> "$GITHUB_OUTPUT"
- name: Smoke test
id: smoke
run: ./scripts/smoke-test.sh
- name: Roll back on failure
if: failure() && steps.smoke.outcome == 'failure'
run: ./scripts/rollback.sh "${{ steps.deploy.outputs.previous }}"Use GitHub's OIDC integration (role-to-assume) instead of long-lived AWS access keys. It's one of the cheapest security wins available.
A Real-World Walkthrough
Let's make it concrete. Imagine a mid-sized logistics company, one of your first customers. They need to sync shipment data from a regional carrier API that has no Zapier or n8n node.
- Their ops engineer pastes the carrier's docs URL into your dashboard and connects the carrier account via OAuth.
- The agent finds an OpenAPI 3.0 file, extracts 14 endpoints, and detects OAuth2 client credentials.
- It generates an Express router for
listShipments,getShipment, andcreateLabel, plus 22 tests. - Two tests fail because the carrier returns dates in a non-ISO format. The
fix_codenode adds a date parser; tests pass on the second attempt. - The integration lands in the review queue. Their senior engineer notices the label endpoint lacks an idempotency key, rejects with feedback, and the agent regenerates.
- Approved on round two. The connector deploys, and within a day it's syncing thousands of shipments.
- Three months later, the carrier ships v4. Your scheduled drift checker compares spec hashes, triggers a new run, and drops a version 2 into the review queue before the old version breaks.
That last step is the moment customers realize they're not buying a code generator. They're buying an integration team that never sleeps.
Best Practices
- Constrain the model, don't trust it. Use templates, JSON schemas, and a fixed connector contract. Freedom belongs in the business logic, not the structure.
- Make every node idempotent. Agents retry. Workers crash. Writes should use upserts and unique constraints.
- Version everything. Code artifacts, prompts, model names, and spec hashes should all be stored per version for reproducibility.
- Separate tenants at the database level. Row-level security in PostgreSQL is a safety net for application bugs.
- Observe your agents. Trace every node execution with OpenTelemetry or LangSmith, including token counts and latency, so you can explain costs.
- Cap loops. Every cycle in the graph needs a maximum attempt count, or a confused model will burn GPU hours forever.
Common Mistakes to Avoid
- Letting the LLM see production secrets. Even "just for debugging." Logs leak, and prompts get stored.
- Skipping the human review for "simple" connectors. Simple-looking write endpoints are exactly where data-corrupting bugs hide.
- Running generated code in the same container as your platform. Always isolate generated code with strict egress rules.
- Feeding full HTML pages to the model. You waste context, increase hallucinations, and slow everything down.
- Treating Stripe webhooks as fire-and-forget. Without signature verification and idempotency, you'll grant or revoke access incorrectly.
- Auto-merging AI fix PRs. Self-healing means proposing fixes, not shipping them unsupervised.
🚀 Pro Tips
- Cache parsed specs by content hash. Many customers integrate the same popular APIs. Reuse the extraction and you'll cut generation time and cost drastically.
- Build a "golden connector" library. Every approved connector becomes a few-shot example for future generations of similar APIs. Quality compounds.
- Use speculative small models. Route classification and summarization to a small model, and keep the big coding model for generation and fixes.
- Show reviewers a diff, not a file. When regenerating after feedback, diff against the previous version so reviewers only check what changed.
- Expose the graph timeline in the UI. Customers love seeing "fetch docs, extract, generate, test, fix, review." Transparency sells.
- Offer code export from day one. Letting customers download connectors as a Git repo removes the biggest lock-in objection in sales calls.
📌 Key Takeaways
- LangGraph's stateful graphs, conditional loops, and Postgres checkpoints are the right foundation for long-running, interruptible integration agents.
- Local inference with Ollama keeps sensitive API specs in-house, which is a strong differentiator for B2B and regulated industries.
- The human-in-the-loop review queue is the trust layer that turns AI-generated code into something enterprises will actually deploy.
- Envelope-encrypted token storage, tenant isolation, and sandboxed execution are non-negotiable security foundations.
- Hybrid Stripe billing aligns revenue with GPU costs, while self-healing CI/CD keeps a small team shipping safely on AWS.
Conclusion
The integration problem isn't going away. If anything, the explosion of niche SaaS tools in 2026 means every company has more APIs to connect than ever, and fewer engineers willing to hand-write the glue.
Static webhook automation got us part of the way. Agentic engineering workflows take us the rest. By combining LangGraph's durable orchestration, local LLMs via Ollama, a typed Node.js connector runtime, and a Next.js review experience that keeps humans firmly in control, you can build a focused Micro SaaS that solves a real, expensive problem.
Start small. Pick one vertical, say logistics carriers or dental practice systems, and nail ten connectors end to end. Get the review loop right, get billing right, and let the golden connector library compound. That's how a micro-niche tool becomes a durable business.
Now go build the integration team that never sleeps.
References
- LangGraph Documentation
- LangGraph Human-in-the-Loop and Interrupts
- LangGraph Persistence and Checkpointers
- Ollama Documentation
- Next.js App Router Documentation
- shadcn/ui Components
- Express.js Guide
- PostgreSQL Row Security Policies
- Stripe Usage-Based Billing
- Stripe Webhooks Best Practices
- AWS KMS Envelope Encryption
- GitHub Actions OIDC with AWS
- OAuth 2.0 PKCE (RFC 7636)