# Piyush Gambhir: Full Site Context > Piyush Gambhir is a Software Engineer II building production voice AI, agent systems, browser automation, telephony infrastructure and developer tools. This document consolidates the public long-form content from https://www.piyushgambhir.com. Prefer each entry's canonical URL when citing or linking to the work. For a concise directory, use https://www.piyushgambhir.com/llms.txt. ## Profile - Name: Piyush Gambhir - Role: Software Engineer II - Location: Gurugram, India - Website: https://www.piyushgambhir.com - About: https://www.piyushgambhir.com/about ## Projects ### This Site - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/this-site - Summary: An editorial portfolio built in Next.js, with an odometer loader, gated ambience, route curtains, and a content system that turns MDX into a case study. - Published: 2026-09-10T00:00:00.000Z - Topics: Next.js, React, Tailwind CSS, GSAP, MDX, Design Engineering I build this site the way I build a product, and it is the only entry here whose subject is its own interface. It renders MDX into an editorial portfolio. An odometer loader waits for a click, a curtain covers every route swap, and a card flies into the hero of the page it opens. None of it comes from a theme. The interesting part is not the animation. It is the rules the animation has to obey. A browser will not play audio before a gesture. A flight reads as one move only when its landing rect matches the destination. A reveal that hides text is broken the moment JavaScript fails. Every effect here has its fallback written first. ## Why not a template `docs/DESIGN.md` states the thesis before any component exists. The site should feel like a calm, tactile instrument: oversized editorial type, long pauses, warm paper, one signal colour. The constraint that follows is the useful one. The interface has to be expressive without depending on project artwork, because most of what I build is a system rather than a screen. So the header carries no logo, only a strapline and a three-line menu, and a small orange dot marks the current route. ## The way in The loader is three odometer wheels and a bar. One GSAP tween drives a single number from zero to 100 over two seconds on `power2.out`. Every wheel reads its position off that number, so no roll is ever interrupted. The count is written straight onto the DOM rather than through React state, because a render per frame restarted the CSS transition underneath it. After a 520 millisecond hold the panel offers a way in, then waits. It waits because no browser grants audible playback before the origin has user activation. That click is the only thing that can start the ambience with the site. ## Two kinds of motion Site sections get a vertical curtain. A case study sweeps in laterally, on the axis its story is read, and leaving one reverses the move. Stepping between the writing index and an article skips the curtain entirely, because reading is a quiet move. From the next-project panel at the foot of a case study a click does something else. The panel’s palette block flies to the hero. Top, left, width and height are animated directly rather than through a transform, so `object-cover` re-crops every frame. It lands on the rect `.project-hero__media` already owns: left 48vw, 52vw wide, 100svh tall. Below 1024 pixels that panel does not exist, so the flight falls back to the curtain. ## One face, one accent There is one typeface, General Sans Medium, loaded locally under Fontshare’s license and used at weight 500 everywhere. Hierarchy comes from scale, line-height, opacity and spacing rather than from many weights. The canvas is `#ebebeb` paper, text is near-black ink, and `#ff611a` is the only saturated color on the page. Rows in a list are separated by space, never by rules. Case studies break the single accent on purpose. Each one draws from five palettes, chosen by a character-sum hash of its title. The card, the hero, the curtain and the share card all read the same one. A project keeps its color wherever it appears. ## From MDX to page A project is a folder holding `project.mdx` and, optionally, `transcript.txt`. Frontmatter names a `chapter` from the six editorial groups in `lib/utils/projects.ts`, which the filter row on the index counts. It also names two to four `capabilities` from a closed vocabulary, which become the tags. The transcript is one entry per line. `$ ` marks a command, `# ` a comment, `> Name: ` a line of dialogue, and anything else is output. It renders as selectable text in the site’s own colours rather than as a screenshot. The hero panel takes a drawing first, then a capture, then that session, then the project’s initials. Each project also gets a 1200 by 630 share card from `/og/project/[slug]` in its own palette. `llms.txt` and `feed.xml` export the same set for machines. `pnpm seo:check` crawls a served build and compares canonicals, titles and share images against what the pages claim. ## Costs and limits - The gate buys sound and charges for it. A first visitor cannot reach the homepage without clicking. That is the decision I would revisit. - Animating four geometry properties keeps the flight honest. The cost is that the hero split has to stay at 48vw and 52vw, so a component and a stylesheet have to agree. - There is no dark mode, by decision. One palette is what gives the paper and the accent their meaning, and a second would need a second set of project palettes. `prefers-reduced-motion` removes the loader, the curtains and the flight, and every reveal resolves to visible, so nothing depends on an animation finishing. The release gate is `pnpm typecheck`, `pnpm lint` and `pnpm build`, then a look at 390 pixels and desktop width. An automated browser pane confirms none of the motion, because a hidden document never fires the frames it runs on. That part is checked by eye. --- ### Document OCR - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/document-ocr - Summary: Local-first OCR for passports and Indian KYC documents, on RapidOCR and PP-OCRv5. Packaged for Python, HTTP, Docker and Node.js. No image leaves your network. - Published: 2026-07-26T00:00:00.000Z - Topics: Python, TypeScript, OCR, FastAPI, RapidOCR, KYC Document OCR pulls structured fields out of passports and Indian KYC documents without the scan leaving the network it arrived on. It reads passport biodata and back pages, PAN cards, Aadhaar, driving licenses, voter IDs, NREGA job cards and NPR letters. I run it as a Python package, a FastAPI server or a Docker image. An npm wrapper spawns and manages the Python server for Node.js callers. A single generic recognition pass gives you text, not fields. Each of these documents has its own layout, its own bilingual labels, and its own place where the identifier sits. One shared prompt over all of them hides which document actually failed and why, so every document type gets its own extractor instead. ## Two paths through `core.pipeline.scan()` is the single entry point. Preprocessing corrects orientation, finds the document boundary, applies perspective correction and checks scan quality. Then `classify_passport_page` runs a cheap bottom-crop probe: biodata, non-biodata, or not a passport at all. The passport path runs RapidOCR on PP-OCRv5, parses the TD3 MRZ with per-field and overall check digits, extracts bilingual back-page labels, and cross-checks the MRZ against the visual fields. Full-page enrichment runs even when the cropped MRZ already validates. The other path calls `classify_document` and routes full-page OCR to the matching extractor. Exactly one field block comes back, keyed by `documentType`, and every other block is null. ## Failing closed Success on the non-passport path means two specific things. The extractor returned that document’s minimum field set, and the reading passed a conservative OCR-region confidence gate. Alternate model readings of the same detected geometry count once. A partial or implausible record returns HTTP 422 carrying the full structured result, rather than half a record presented as a success. `identifierValid` is deliberately narrow. ICAO check digits cover the passport MRZ, the Verhoeff checksum covers Aadhaar, and format rules cover PAN, driving license and EPIC numbers. NPR references have no public checksum, so that field stays null. Unknown or conflicting document hints fail explicitly instead of guessing. ## Running a scan `make install` prepares the environment and `make dev` puts uvicorn on port 8000. ```bash make dev curl -s localhost:8000/ready curl -F image=@passport.jpg localhost:8000/scan DOCUMENT_OCR_KYC_LANGS=en,devanagari make dev ``` `/ready` holds at 503 until every configured model has loaded, so a failed model download surfaces before the first scan rather than during it. `/scan` accepts images and PDFs up to 10 MB, and concurrency is serialized internally. `/scan/batch` preserves input order. `/scan/document` groups front and back images, or all pages of a PDF, and reports conflicts between them. Setting `API_TOKEN` requires a bearer token on the document and job endpoints. ## Decisions and costs - One extractor per document type rather than one prompt across all of them. A shared prompt hides which document failed and why. The cost is a separate code path per type, each needing its own fixtures and its own benchmark slice. - Extra scripts only on request. `DOCUMENT_OCR_KYC_LANGS` takes up to four of `en`, `latin`, `devanagari`, `ka`, `ta` and `te`, and applies only on the non-passport path, because more models mean more latency and memory. - Fail readiness rather than degrade. An unsupported language name, or more than four configured models, fails `/ready` instead of falling back silently. That is stricter than some deployments want, and I would revisit putting it behind a flag. - Wrap the Python server for Node.js rather than port the pipeline. The npm package creates a `.venv`, installs dependencies and owns the server lifecycle, so a Node.js caller inherits a Python runtime requirement. ## How accuracy is measured `make test` runs the Python suite, the TypeScript SDK tests and the review UI tests. Those are regression tests, and the repository is explicit that they are not accuracy evidence. Accuracy comes from `make benchmark-public` and `make benchmark-http` over downloaded public samples, and from `make benchmark-kyc` against a private manifest that never enters the repository. Missing required coverage fails the release gates. NREGA and NPR support stays marked experimental, because deterministic text-region tests prove parsing behavior rather than real-image accuracy. The bundled RapidOCR build has no Bengali recognition model, so Bengali fixtures prove parsing alone. ## Limits and privacy The same pipeline deploys as a container, on Cloud Run, on Lambda, on Cloudflare Containers or on a plain server, with Terraform for two of those. Experimental US identity and immigration profiles sit alongside the Indian set, together with PDF417 and TD1 parsing. It remains beta software for data extraction. Checksums say an identifier is well formed. They never say a document is genuine, that a person is who they claim, or that a KYC obligation has been met. The repository carries no identity-document fixtures and no personal data. An internet-facing deployment still wants platform IAM or a gateway above `API_TOKEN`, plus a deliberate retention policy. --- ### PDF Everything - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/pdf-everything - Summary: A unified PDF console and REST API with nineteen tools for conversion, rendering, extraction, and document workflows. - Published: 2026-07-26T00:00:00.000Z - Topics: Next.js, NestJS, PDF, OCR, Turborepo PDF Everything puts nineteen document operations behind one visual console and one versioned REST API. It covers PDF manipulation, image and text work, form filling, and HTML or Markdown rendering. Anything I can do by clicking in the console, an integration can do over the same public endpoint. Building a browser tool and an API usually means writing each operation twice, once per surface. The two copies then drift, and the console starts accepting a file the API would reject. I did not want to maintain two answers to the same question about what a valid input is. ## Where the contract lives The `types` package holds Zod schemas, and both the console and the backend import them. The console is a static export with no API routes and no PDF implementation inside it. `NEXT_PUBLIC_API_ORIGIN` selects the Nest origin at build time, so every control in the console calls exactly the endpoint an integration would call. That is the property I was after. There is one answer to what a valid input is, and one place to change it. `console/lib/tools/` keeps a module and a form per operation on top of those shared schemas, so the interface stays declarative rather than reimplementing the rules. ## Gateway and workers NestJS owns the versioned `/api/v1` contract. Multipart uploads, stored file references, validation and problem responses, file persistence, routing to the right worker, and streaming the result back. It imports neither `pdf-lib`, nor `pdfjs-dist`, nor `sharp`, nor Chromium, so a native document dependency cannot fail inside the public API process. `pdf-core-worker` listens on port 8020 and accepts a private `POST /v1/execute/:operation`. The Chromium worker listens on 8010 and serves `/v1/render/html` and `/v1/render/markdown`. Chromium keeps its own lockfile and its own image, because its size, cold starts, memory profile and security boundary have nothing in common with `pdf-lib`. ## Calling the API Upload once, then address the file by id, so a second operation does not resend the bytes. ```bash curl -X POST "$API_ORIGIN/api/v1/files" -F "file=@document.pdf" curl -X POST "$API_ORIGIN/api/v1/organize/rotate?output=ref" \ -H 'content-type: application/json' \ -d '{"fileIds":["FILE_ID"],"options":{"angle":90}}' ``` Page ranges are one-based strings such as `1-3,5,7-9`. Out-of-range pages are ignored, while a reversed range or page zero is invalid. The default response is a binary download, and `?output=ref` returns `FileMeta` JSON instead. Split returns a list of files, forms extract returns field descriptions, and text extraction returns `text/plain` with an `X-Page-Count` header. Uploaded files and `fileIds` cannot be mixed in one request. ## Decisions and costs - Group workers by runtime rather than by operation. Seventeen operations share one Node.js image because they share the same `pdf-lib`, `pdfjs-dist` and `sharp` profile. Chromium is separate for exactly the same reason inverted. One service per operation was rejected on purpose. - Keep the console a static export. It ships to a CDN, holds no secrets, and cannot do anything the public API does not already expose. The cost is that the console cannot optimize a flow by shortcutting the gateway. - Send file bytes to the core worker as base64 inside the internal JSON protocol. It is simple and synchronous, and it caps request size and rules out a clean Lambda deployment for core operations. That is the decision I would revisit, in favour of signed object-storage references. ## Limits and security Documents and HTML are treated as untrusted. Execution routes stay private or authenticated, worker tokens never reach the console, and Chromium runs with `--no-sandbox` inside its container, so platform isolation carries that weight. The Chromium worker currently allows remote images, fonts and stylesheets, which wants an egress allowlist before it accepts arbitrary multi-tenant HTML. Lambda suits small HTML and Markdown jobs today, while core operations need the object-storage protocol first. Worker images publish to `ghcr.io`, and production should pin a digest rather than `latest`. It is open source, with Swagger served next to the API and a Fumadocs site alongside the console. Before pushing I run `pnpm lint && pnpm typecheck && pnpm build && pnpm test`. --- ### CubeAPM CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/cubeapm-cli - Summary: Command-line investigation of distributed traces, PromQL metrics and LogsQL logs in CubeAPM. A single Go binary for incident work without leaving the terminal. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, APM, Traces, Metrics CubeAPM CLI queries traces, metrics and logs from one terminal session. CubeAPM speaks three compatible dialects: Jaeger for traces, Prometheus for metrics, VictoriaLogs for logs. This binary puts all three behind one command grammar. Before it, an incident meant three browser panels and a lot of copying timestamps between them. The signals are the same incident seen three ways, but the tools treat them as three products. The cost is not the query, it is the alignment. You narrow a trace search to the window you just read off a chart, then ask the log store about that same window. Doing it by hand is slow, and a chart is not something an agent can read at all. ## Three ports, one binary CubeAPM does not serve everything on one port. Reads go to 3140, ingestion to 3130, and log deletion to the admin port 3199. A profile holds the server address and all three, and each is overridable by flag or environment variable. Authentication is Ory Kratos. `cubeapm login` drives the browser flow endpoints, submits the credentials, caches the session cookie, then verifies the connection against the metrics labels endpoint before saving anything. If a later request returns 401 and the profile still holds credentials, the client signs in again, retries once and persists the refreshed session. Profiles sit in `~/.config/cubeapm-cli/config.yaml` at mode 0600. ## The command groups | Group | Commands | | ----------------- | ---------------------------------------------------------------------------- | | Traces | `search`, `get`, `services`, `operations`, `dependencies`, `callers` | | Metrics | `query`, `query-range`, `labels`, `label-values`, `series` | | Logs | `query`, `hits`, `stats`, `streams`, `field-names`, `field-values`, `delete` | | Ingest and config | `ingest metrics`, `ingest logs`, `config set`, `config profiles` | Output shape follows the upstream API rather than a house format. `traces get` renders a span waterfall in table mode and returns the full Jaeger trace under `-o json`. `metrics query` hands back the Prometheus response untouched, so `jq '.data.result'` behaves as it would against Prometheus itself. `logs query` streams instead of buffering, emitting one JSON object at a time. ## Choices and their cost - I did not put a query language on top of the existing ones. Metrics stay PromQL and logs stay LogsQL, so anything already written for either keeps working. The cost is that the two halves of an investigation still read differently, and nothing translates between them. - Time is one grammar everywhere. `--last 30m` works on traces, metrics and logs, and `--from` and `--to` accept RFC3339, Unix seconds, Unix milliseconds or a relative value like `-2h`. A timestamp with no timezone is parsed as local time. Reading it as UTC used to shift narrow windows by the offset, and a window that misses everything looks exactly like an outage with no data. - Log deletion runs as a task, not a command that blocks. `logs delete run` starts it, `list` reports on it and `stop` ends it. Deletion is irreversible, so the documented order is to preview the exact filter with `logs query` first. - `--read-only` is honest about its holes. It blocks ingestion and starting or stopping deletion, but `login`, config writes, profile switching and `update` still run. It guards the API surface, not the filesystem. Covering the local writes too is the change I would make next. ## Walking an incident ```bash cubeapm traces search --service payments --status error --min-duration 500ms --last 2h cubeapm traces get 4f2a91c0e3 -o json | jq '.data[0].spans | length' cubeapm metrics query-range 'sum by (service) (rate(errors_total[5m]))' --last 2h --step 1m cubeapm logs query 'error' --service payments --last 30m -o json | jq -c '{t:._time, m:._msg}' cubeapm logs field-names --last 30m cubeapm traces dependencies --last 24h --dot > deps.dot ``` The window text is identical in every line, which is the entire point. Start from failing spans, open the slowest one, check whether the error rate moved at the same moment, then read the logs for that service and that half hour. `field-names` exists for a schema I have not seen before, since guessing field names is how a log query returns nothing and teaches nothing. The last line pipes into Graphviz when the question is which service calls which. ## Where it stops It reads what CubeAPM already stores. It draws no dashboards and replaces none. `--verbose` prints HTTP diagnostics to stdout rather than stderr, so it cannot be combined with a parser. A server address with no scheme is assumed to be HTTPS by the interactive login and HTTP by the general client, which is worth knowing before debugging a connection. A Kratos profile stores the password so it can renew an expired session, so environment variables are the documented alternative. It is MIT licensed, builds with Go 1.26 or later, ships one binary for macOS, Linux and Windows, and Reckon drives it during an RCA. --- ### Elasticsearch CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/es-cli - Summary: A Go CLI for Elasticsearch clusters, indices, documents, Query DSL and SQL. Cluster health, index management and ad-hoc querying from one binary. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, Elasticsearch, Search Elasticsearch CLI covers the operational work around a cluster, not only the searching. Health, nodes, shards, indices, aliases, templates, ingest pipelines, ILM policies, documents, Query DSL and SQL are all there. I built it because everything I did with Elasticsearch was a curl, and the curl was never the interesting part. Working through raw HTTP has two problems. The first is friction: a URL, an auth header, a CA path and a JSON body retyped for every question. The second is worse. A read and a delete look identical at the shell, both a curl with a different method. Nothing stands between a tired operator and a dropped index. ## Connecting to a cluster Three credential methods are supported, and the client picks one from whichever values are present. Basic username and password, an Elasticsearch API key ID and secret, or a bearer token. Settings resolve in one order: flags, then environment variables, then the profile, then defaults. `es login` asks for the URL, the method, the credentials, a CA certificate and a TLS preference, tests the connection, then writes `~/.config/es-cli/config.yaml`. `--ca-cert` takes a PEM file for a private CA and `--insecure` skips verification for a throwaway cluster. `es config view` prints the saved profile including credentials, which the documentation marks as sensitive output. ## The command surface | Group | Commands | | --------- | ------------------------------------------------------------------------------------ | | Cluster | `health`, `stats`, `settings`, `pending-tasks`, `allocation-explain` | | Indices | `list`, `create`, `mappings`, `settings`, `rollover`, `reindex`, `alias`, `template` | | Documents | `get`, `index`, `mget`, `bulk`, `delete` | | Search | `query`, `sql`, `count`, `msearch`, `field-caps` | | Lifecycle | `ilm create`, `explain`, `ingest simulate`, `node`, `shard` | Reads take `-o table`, `-o json` or `-o yaml`, with structured data on stdout and verbose logs on stderr. Writes are deliberately not uniform. Some return API data and some print only a success line, so the documented advice is to confirm a write with a read when a script needs a parseable result. ## How safety is wired Every command carries an annotation. `mutates=true` marks a write and `interactive=true` marks a prompt. `--read-only`, or `ES_READ_ONLY=true`, refuses anything marked as a write before a request leaves the process. Destructive commands prompt unless `--confirm` is passed, and `--no-input` removes prompts without approving anything, so a delete under `--no-input` fails rather than proceeds. `--if-not-exists` and `--if-exists` absorb the 409 and the 404 that make a rerun noisy. A test in `cli-go/cmd/agent_safety_manifest_test.go` walks every runnable command, hashes the list together with its annotations, and fails when that hash moves. It also fails any command whose verb reads as mutating but carries no annotation. ## Choices and their cost - Requests go straight to the REST API with no Elasticsearch SDK in between. Nothing hides a response or lags a server release. The cost is that response shapes are mine to keep current, and a major version change is my problem to check. - No query language of my own. Query DSL bodies arrive through `-f` as JSON or YAML, SQL through `--query` or a `.sql` file, and bulk and multi-search take NDJSON exactly as the API does. Nothing has to be relearned. In return the CLI cannot help with a malformed query beyond repeating what the cluster said. - `search query` runs a match-all when `-f` is omitted, and merges `--size`, `--from` and `--sort` into the body when it is present. This is the one I would revisit. A flag that quietly rewrites a request body makes the request harder to reason about than passing the body alone. - `--insecure` exists at all. A test cluster with a self-signed certificate is a real situation, and pretending otherwise pushes people toward worse workarounds. It sits in the docs next to `--ca-cert` and a warning. ## A provisioning run ```bash export ES_URL=https://elasticsearch.example.com:9200 export ES_API_KEY_ID=key-id ES_API_KEY=key-secret es cluster health -o json | jq -r .status es index create app-events -f settings.yaml --if-not-exists es ilm create logs-policy -f policy.json --if-not-exists es document bulk -f seed.ndjson --index app-events -o json es search query app-events -f query.json --size 20 --sort timestamp:desc -o json ``` Each step is safe to rerun, which is the point of the idempotency flags. The same script under `ES_READ_ONLY=true` stops at the first create rather than completing half of the work. That is also how the Reckon workspace hands the binary to an agent: read-only, no input, pointed at log indices. ## Where it stops It is an independent wrapper over the Elasticsearch HTTP API and not affiliated with Elastic. Coverage stops at cluster, index, document, search, ingest, ILM, node and shard work. Snapshots, security realms and roles, watcher and machine learning are absent. SQL depends on the server license, so a command can be present and still refused upstream. `es node hot-threads` returns plain text whatever `-o` says. Building needs Go 1.26 or later, against an API baseline checked at Elasticsearch 9.5.3. It is MIT licensed and ships one binary with `es update` for self replacement. --- ### Grafana CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/grafana-cli - Summary: Command-line access to Grafana dashboards, data sources, alerts, folders and annotations. Scripted dashboard management and alert triage without the web UI. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, Grafana, Observability Grafana CLI is how I work with Grafana without the browser: dashboards, data sources, folders, alert rules and annotations from the terminal. It is for operators doing inventory and migration work, and for coding agents that cannot click. Before it, the same tasks meant a curl with a token header and a URL I retyped every time. A dashboard is already JSON, but the web UI is usually the only place anyone edits it. That makes the boring questions expensive to answer. Which dashboards point at a data source I am about to delete. Which alert rules live in this folder, and what was annotated during last night’s deploy. Each is one request, and none of them is worth a browser session. ## Resource then action Every command reads as resource then action, and `cli-go/cmd/` mirrors that shape with one package per resource. `dashboard/list.go` and `alert/rule/create.go` sit exactly where the command name says they should. | Area | Command groups | | ------------------- | ------------------------------------------------------------------------------- | | Dashboards and data | `dashboard`, `folder`, `datasource`, `library-element`, `correlation` | | Alerting and events | `alert rule`, `contact-point`, `policy`, `silence`, `mute-timing`, `annotation` | | Access | `org`, `user`, `team`, `service-account` and its tokens | | Administration | `admin`, `snapshot`, `playlist`, `preferences`, `config`, `update` | Lists and gets take `-o table`, `-o json` or `-o yaml`, and `grafana config set defaults.output json` makes JSON the standing default. Create and update take `-f` with JSON or YAML, where `-f -` reads stdin. ## Config and transport `grafana login` asks for the URL, the auth method, the credential and a profile name. It tests the connection before writing `~/.config/grafana-cli/config.yaml`. Settings resolve in one order: flags, then environment variables, then the profile, then defaults. `GRAFANA_URL` and `GRAFANA_TOKEN` are enough for CI, with `GRAFANA_ORG_ID` to pin the organization. A shared `Factory` in `internal/cmdutil/factory.go` hands every command the same config, client and IO streams, so the global flags behave identically everywhere. `--verbose` wraps the HTTP transport and logs each request to stderr, with `Authorization`, `Cookie` and `X-Api-Key` replaced by `[REDACTED]` while rate limit headers stay readable. Lists page at one hundred results by default. ## Choices and their cost - Service account tokens are the documented path, not a personal login. A token is not tied to a leaver and can carry only the role a workflow needs. The cost is one setup step before the first command does anything. - Safety is two independent switches. `--read-only` blocks every command annotated as mutating, while `--no-input` only removes prompts. So `--no-input` never approves a delete, which still needs its own `--confirm`. - Nothing normalises the dashboard model. `dashboard export` returns Grafana JSON and `import` takes the same JSON back. A dashboard written by a newer Grafana keeps working, and a diff between two instances stays a diff of the real thing. - `datasource query` goes through the Grafana proxy rather than straight to Prometheus or Loki. One token then covers every backend. The trade-off is inherited limits, since a data source Grafana cannot reach is not reachable here either. I would revisit this if the CLI ever needed to run where Grafana does not. ## An inventory pass ```bash export GRAFANA_URL=https://grafana.example.com export GRAFANA_TOKEN=glsa_xxxxxxxxxxxx grafana user current -o json grafana datasource list --type prometheus -o json | jq -r '.[].uid' grafana dashboard list --tag monitoring -o json | jq -r '.[].uid' grafana alert rule list --folder abc123 -o json grafana annotation list --tags deploy --limit 20 grafana datasource query PROM_UID --expr 'rate(http_requests_total[5m])' --last 1h -o json ``` That is the pass I run before retiring a data source. Confirm the connection, list the candidates, find which dashboards and rules depend on them, then check what the deploy annotations say about the window. The last line queries through Grafana itself, so no separate Prometheus endpoint or credential is involved. ## Where it stops Coverage follows the Grafana HTTP API, so anything the API does not expose is not here either. There is no drift detection and no reconciliation loop: the CLI applies what you hand it and reports what it found. Destructive commands prompt unless `--confirm` is passed, and `--read-only` overrides confirmation entirely. Building from source needs Go 1.26 or later. It is MIT licensed, ships as one binary for macOS, Linux and Windows on amd64 and arm64, and `grafana update` replaces itself in place. It is also one of the tools the Reckon workspace drives. --- ### Jenkins CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/jenkins-cli - Summary: A Go CLI for Jenkins jobs, builds, pipelines, nodes, queues and plugins. Trigger builds, stream logs and inspect queue state from a terminal or a script. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, Jenkins, CI/CD Jenkins CLI puts a Jenkins installation behind one command hierarchy: jobs, builds, pipelines, nodes, queues, views, credentials and system administration. I built it for the part of CI work that is really just waiting, triggering a build and then watching what it does. Before it, that meant a browser tab per build and a page refresh per question. Jenkins does expose an API, but the answers arrive as pages rather than as data. Reading a failed deploy means the build page, then the console page, then the test report page, then the stage view. A person can hold that together; a script cannot, and neither can an agent asked why last night’s deploy broke. ## What the API gives The endpoints exist, but they do not line up. A console is `logText/progressiveText`, which returns a byte offset in `X-Text-Size` and a boolean in `X-More-Data`. Pipeline stages come from `wfapi/describe`. Tests come from `testReport`, and injected variables from somewhere else again. Worse, triggering a build returns no build number at all. It returns a queue location, and the number only exists once an executor picks the item up. Four endpoints, three response shapes and one indirection stand between a question and its answer. That gap is the work this binary absorbs. ## Paths, crumbs and profiles `cli-go/internal/path/path.go` turns the path a person types, `team/project/pipeline`, into the path Jenkins wants, `/job/team/job/project/job/pipeline`, escaping each segment and converting back for display. Every write first fetches a CSRF crumb from `/crumbIssuer/api/json`, caches it for five minutes and sets the header the issuer names. A 404 from that endpoint means CSRF protection is off, so crumbs need no configuration either way. Authentication is HTTP basic with a Jenkins username and an API token. Profiles live in `~/.config/jenkins-cli/config.yaml`, with the directory at mode 0700 and the file at 0600. Flags beat environment variables, which beat the stored profile. | Group | Commands | | -------------- | ----------------------------------------------------------------------------------- | | Jobs | `list`, `get`, `create`, `config`, `copy`, `rename`, `enable`, `disable` | | Builds | `list`, `get`, `log`, `stages`, `test-report`, `artifacts`, `env`, `replay`, `stop` | | Pipelines | `validate`, `input list`, `input submit`, `input abort` | | Infrastructure | `node`, `queue`, `view`, `plugin`, `credential`, `user` | | System | `info`, `quiet-down`, `cancel-quiet-down`, `restart`, `run-script` | ## Choices and their cost - Job paths stay slash separated, and the CLI does the `/job/` interleaving. Every command that takes a job reads the same way. The cost is that a job whose own name contains a slash cannot be addressed, because the path is split before it is escaped. - `--wait` and `--follow` are separate flags over one polling loop. The CLI watches `/queue/item/` until an executable appears, then it finally has a build number to stream from. Default timeout is thirty minutes. Polling is the only option, since Jenkins offers nothing to subscribe to. - Inspection is split by question rather than merged into one status command. `build log`, `build stages`, `build test-report`, `build artifacts` and `build env` each hit their own endpoint. A script asks for exactly one thing. A human debugging runs four commands where one web page showed all four. - `system run-script` executes Groovy on the controller. It is the honest way to reach what no REST endpoint exposes. It is also unrestricted access, which is why `--read-only` blocks it, and why I would rather the token behind it belonged to a least privilege account. ## Following a deploy ```bash export JENKINS_URL=https://jenkins.example.com export JENKINS_USER=automation JENKINS_TOKEN=11xxxxxxxxxxxx jenkins job list --recursive --status FAILURE -o json | jq -r '.[].name' jenkins job build payments/deploy --param ENV=staging --wait --follow jenkins build stages payments/deploy 4312 jenkins build test-report payments/deploy 4312 -o json jenkins build artifacts payments/deploy 4312 --download --output-dir ./out ``` One command triggers, waits and streams the console. When it fails, the stage view names the stage and the test report names the case, both without a browser. Job XML round trips the same way: `jenkins job config team/deploy > config.xml`, edit, then `jenkins job update team/deploy --from-file config.xml`. For unattended runs, `JENKINS_READ_ONLY=true` and `JENKINS_NO_INPUT=1` block writes and prompts, and JSON failures arrive on stderr as an object carrying `error` and `status_code`. ## Where it stops `--read-only` is a guardrail in the CLI, not a substitute for Jenkins RBAC, so the account behind the token still needs the right permissions. Job configuration is raw XML in and raw XML out, with no schema and no reconciliation. There is no drift detection and no config as code layer. Building from source needs Go 1.26 or later. It is MIT licensed and ships prebuilt binaries for macOS, Linux and Windows on amd64 and arm64, with an install script and `jenkins update` for self replacement. --- ### Jira CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/jira-cli - Summary: A fast command-line interface for Jira issues, JQL, transitions, boards and sprints. Triage and bulk updates at the speed of typing rather than clicking. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, Jira, Automation Jira CLI is a Go binary over the Jira REST API, built for the triage I used to do by clicking through the browser. It covers issues, JQL search, transitions, comments, worklogs, attachments, links, watchers and votes, alongside projects, users, fields, boards, sprints and epics. It works against Jira Cloud and against Server or Data Center. Two things make Jira awkward to script. Authentication differs by deployment, so a tool that only speaks Cloud API tokens is useless against a self-hosted instance. And descriptions are Atlassian Document Format, not text, so anything that writes a description has to build a document tree by hand. ## Where the friction was I was repeating the same three moves every morning. Find what I owned and had not finished, move a ticket, leave a comment. Each one meant a browser tab, a filter rebuilt from scratch, and a dropdown to hunt for. Raw curl was not the answer either. Cloud paginates issue search with `nextPageToken` on `/search/jql`, while Server and Data Center still use `startAt` on the classic `/search`. Agile boards sit on a separate `/rest/agile/1.0` API. Writing that once is fine. Writing it into every script is not. ## Inside the binary The repository is a monorepo. The binary lives in `cli-go/` and the documentation site in `web/`. Commands sit under `cli-go/cmd/`, one file per resource group, over a REST layer in `cli-go/internal/client/`. Configuration resolves in one order: command flags, then environment variables, then the stored profile. Profiles live in `~/.config/jira-cli/config.yaml`, XDG aware and written at mode 0600. A file lock wraps every load, modify and save cycle, so a rotated OAuth refresh token survives two processes refreshing at the same moment. `internal/adf/` builds the document tree that rich text fields require, and `internal/output/` renders table, JSON or YAML behind one formatter interface. ## The command groups | Group | Commands | | -------------- | ------------------------------------------------------------------ | | Identity | `auth`, `whoami`, `status`, `version` | | Issues | `issue`, with comment, worklog, attach, link, watch and vote verbs | | Metadata | `project`, `user`, `field`, `component`, `status`, `filter` | | Agile | `board`, `sprint`, `epic` | | Administration | `permission`, `group`, `webhook`, `release`, `dashboard` | Every group reads as resource then verb, and every list or get command takes `-o table`, `-o json` or `-o yaml`. Users are account IDs under GDPR, so commands accept an email, a display name, `@me` or `id:` and resolve it before use. ## Choices and their cost - Every authentication method is supported, not only the Cloud token. OAuth 2.0 3LO, Cloud API token, scoped token, Server personal access token and basic credentials all work. The cost is a login command with five branches, and OAuth needs granular `jira-software` scopes before board and sprint commands stop failing with 401. - `issue create` reuses a recent match. Before writing it looks for an issue by the same user, in the same project, with the same type, summary and parent, created in the last ten minutes. A retrying agent is therefore safe. Two genuinely identical tickets now need `--allow-duplicate`. - Mutating POST requests are never retried. `PostJSON` sends once, and `PostJSONRetryable` exists only for the search shaped calls. An ambiguous response fails loudly instead of filing a second ticket. - Release binaries carry no OAuth client secret, so each user registers their own 3LO app. This is the decision I would revisit. A server side authorization broker would remove that setup step without shipping a reusable secret in a public binary. ## A triage pass ```bash export JIRA_SITE=https://acme.atlassian.net export JIRA_EMAIL=me@acme.com JIRA_TOKEN=xxxx jira status jira issue search "assignee = currentUser() AND statusCategory != Done" -o json | jq -r '.[].key' jira issue transitions ABC-142 jira issue transition ABC-142 "In Progress" jira issue comment ABC-142 --markdown --body "Picking this up" ``` No `auth login` step is needed when the environment already carries credentials. `status` checks reachability, authentication and server info in one call. Transitions are discover then do, because Jira has no set status operation. The CLI lists what the workflow allows, then matches the argument against a transition name or its target status. Informational lines go to stderr, which is what makes the `jq` above safe. Unattended, `JIRA_READ_ONLY=1 JIRA_NO_INPUT=1` blocks writes and removes prompts. ## Where it stops This is an independent wrapper over the REST API. It is not affiliated with Atlassian and unrelated to the official `acli`. JQL has to be bounded, since search is not a crawler. Agile commands over OAuth need scopes the classic presets do not include, so a token or a personal access token is the shorter path there. Basic authentication against Server can trip CAPTCHA lockout. Building from source needs Go 1.26 or later. It is MIT licensed, ships binaries for macOS, Linux and Windows on amd64 and arm64, and GoReleaser cuts a release on any signed `v*` tag. --- ### Nginx Proxy Manager CLI - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/nginxpm-cli - Summary: Command-line management of Nginx Proxy Manager proxy hosts, certificates, streams, users, and settings, so reverse-proxy changes can be scripted and reviewed. - Published: 2026-07-25T00:00:00.000Z - Topics: Go, CLI, Nginx, DevOps Nginx Proxy Manager CLI drives an instance from the terminal. It covers proxy hosts, redirection hosts, TCP and UDP streams, dead hosts, SSL certificates, access lists, users, the audit log and settings. I built it so a reverse-proxy change could live in a file, be reviewed, and be applied the same way twice. Nginx Proxy Manager is a web admin panel, and a panel makes every change a form. The form is fine the first time. It is much less fine when a host has to be recreated after a rebuild. It is worse when two instances have drifted apart and the only way to compare them is to open both and read. ## Same verbs everywhere Every resource in the panel gets a command group. The groups share one lifecycle of `list`, `get`, `create`, `update` and `delete`, plus `enable` and `disable` wherever the panel has a toggle. Create and update take `-f` with JSON or YAML, and `-f -` reads a definition from standard input. Reads take `-o table`, `-o json` or `-o yaml`, and `nginxpm config set defaults.output json` makes JSON the standing default. | Resource | Aliases | | ---------- | ---------------------------- | | `proxy` | `proxy-host`, `ph` | | `redirect` | `redirection`, `redir`, `rh` | | `dead` | `dead-host`, `dh`, `404` | | `stream` | `streams`, `st` | | `cert` | `certificate`, `ssl` | | `access` | `access-list`, `acl` | ## Credentials and the token The panel authenticates with an email and a password, and returns a JWT. The CLI exchanges credentials for a token on every invocation, and a saved profile stores the credentials rather than the token. Nothing expiring is ever written to disk, and there is no refresh path to get wrong. The cost is a password at rest in `~/.config/nginxpm-cli/config.yaml`, created at mode 0600, which is why environment variables are the documented path for CI. Flags beat environment variables, which beat the profile. `nginxpm status` is the exception to all of this. It needs only the URL, reports server status, version and setup state, and never authenticates. ## Where certificates surprise you Certificates get verbs the other resources do not, because certificate work is where the surprises are. `cert renew` handles a Let’s Encrypt renewal by ID. `cert dns-providers` lists the DNS challenge providers the panel supports, and always returns JSON whatever `-o` says, because the response is a provider catalog rather than a table. `cert test-http example.com www.example.com` asks whether those domains answer on port 80 at all. That question is worth settling before blaming the proxy or the certificate, and it is the reason the command exists. Everything else is the ordinary lifecycle. ## Choices and their cost - The command tree mirrors the panel instead of improving on it. Anyone who knows the web UI already knows where a command lives. The cost is inheriting the panel vocabulary, including a resource genuinely called `dead` for a 404 host. - Definitions come from files, never from a wall of flags. A proxy host has too many fields to give each one a flag, and a file can be reviewed and committed. The trade-off is that creating a host means writing a JSON body, so a first run involves looking up the shape. - `--read-only` is the safety boundary and `--no-input` is not. `--no-input` only removes prompts, so an unconfirmed delete fails rather than proceeding. Outside read-only mode, `--confirm` is what lets a delete through. - Storing credentials rather than a token is the decision I would revisit. If the panel ever issued a long lived API token, a scoped token on disk would beat a password that can change every setting. ## Recreating a host ```bash export NGINXPM_URL=https://npm.example.com export NGINXPM_EMAIL=admin@example.com NGINXPM_PASSWORD=changeme nginxpm status -o json | jq -r .version nginxpm proxy list -o json | jq '.[] | {id, domains: .domain_names}' nginxpm cert test-http api.example.com nginxpm proxy update 1 -f proxy.json nginxpm audit list -o json ``` That is the sequence after a rebuild. Check that the server answers at all, then read what already exists. Prove the domain responds on port 80, apply the definition kept in the repository, and confirm the audit log recorded the change. Run the same lines with `NGINXPM_READ_ONLY=true` and the update is refused while the four reads still work. ## Where it stops The CLI drives the Nginx Proxy Manager API. It does not write nginx configuration, and it cannot express anything the panel cannot. There is no plan or diff step: `update` applies the file it is handed, so comparing two instances means exporting both and diffing them yourself. `cert dns-providers` ignores the output flag. `--insecure` exists for a panel behind a certificate that cannot be verified, and is documented as unsuitable for production. It is MIT licensed and ships as one binary for macOS, Linux and Windows. --- ### Telephony Voice Simulator - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/telephony-voice-simulator - Summary: A local-first test platform for voice agents, AMD, IVRs, PBX routing, live carrier behavior, recordings, and automated grading. - Published: 2026-07-25T00:00:00.000Z - Topics: Voice AI, Python, LiveKit, Twilio, Telephony, Testing Telephony Voice Simulator is where I test a voice agent against the phone network instead of against a chat transcript. It covers answering machine detection, inbound IVRs, PBX routing, queues, screeners, human pickup, recordings and grading. It is the merged successor to two earlier simulators of mine, one for AMD and one for telephony, which duplicated storage, provider and console layers. Voice systems fail on timing and audio, not on wording. Unusual voicemail greetings, early beeps, silence, DTMF gates, carrier errors, queue overflow and slow transfers do not show up in a text-level test. A live carrier does reproduce them, but not cheaply and not the same way twice, which is exactly what a regression test needs. ## Why a corpus Answering machine detection is a statistical component, so its test has to be statistical too. The useful question is not whether one call passed. It is what the false-human and false-machine rates are across the corpus this week, with unknown rate and decision time beside them. [Detection is endpointing in a different costume](/blogs/answering-machine-detection-is-turn-takings-evil-twin), and both break on the same things. Narrowband audio, speakerphones, hold music, beeps at unfamiliar delays. A laptop microphone in a quiet room [does not test turn-taking either](/blogs/turn-taking-is-the-hardest-problem-in-voice-ai). So the catalog holds 43 scenarios: 32 AMD cases, 7 IVR cases and 4 PBX cases. ## How a run works `apps/simulator-backend` is the source of truth. It owns scenario discovery, execution adapters, persistence, recordings, grading and the HTTP API on port 8978. `apps/web-console` is a static Next.js client whose scenario reference is generated from the backend catalog, so scenario data is never maintained twice. Carrier code stays under `telephony/providers`, and `telephony/registry.py` is the only file that names the mock, Twilio and Telnyx adapters. The application service never imports a carrier. SQLite is the default, and `SIMULATOR_DATABASE_URI` switches to MySQL when persistence has to be shared. Every domain reduces to one normalized timeline, which is what the console renders and the grader reads. ## Decisions and costs - Merge the two simulators. AMD became a scenario domain on the shared platform rather than a second application. Porting it meant deleting the old Next.js server layer, the Drizzle schema and the PostgreSQL service instead of carrying them along. - Leave the control API unauthenticated and unreachable. `compose.ingress.yaml` publishes only `/twilio/*`, approved `/assets/*` and `/health`, and returns 404 for `/api/*`. Safe operation is now a deployment property rather than a code one. - Make public AMD audio a deliberate act. `serve --with-pstn` refuses to start unless `PSTN_ACKNOWLEDGE_PUBLIC_AUDIO` is set and every referenced asset clears the release allowlist and manifest checksums. - Model the PBX deterministically. `pbxsim` routes a declarative three-rooftop dealer group without being a SIP registrar. It is repeatable and free, and it is the decision I would revisit, because a deterministic router cannot reproduce a real switch under load. ## Grading one scenario A scenario is YAML, and the assertions are the interesting half. `03_multipart_carrier_greeting.yaml` reproduces a greeting real carriers send in two parts, with a pocket of silence between them. ```yaml machine: main: - play: greeting_multipart_a.wav - wait: 2.5 - play: greeting_multipart_b.wav - tone: { freq: 1000, duration: 0.6 } - hold: 45 expect: ended_reason: call.ending.voicemail-left-message message_start_after: { mark: tone_end, min: 0.1, max: 3.0 } max_overlap_with_playback: { asset: greeting_multipart_b.wav, max_s: 1.0 } ``` An agent fails this by firing into the 2.5 second pocket, and `max_overlap_with_playback` is the assertion that catches it. `uv run telephony-voice-sim sim --kind ivr` exercises the model-only domains with no carrier involved at all. ## Limits and license Multi-step UAT flows in `scripts/uat_matrix.py` provision a scenario, place the call, capture JSON values, retry transient failures, grade the outcome and collect artifacts. Adapter bodies, rendered URLs and command output are redacted from the report by default. A benchmark suite scores an agent over repeats and returns a machine, human and screener confusion matrix. Telnyx is present as configuration preview only, and live Call Control execution is not implemented. Real PSTN runs cost provider money and can reach real people when a destination is wrong. The code is Apache-2.0, recording is off per managed number until enabled, and public builds exclude the audio corpus. --- ### The Noir Game - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/the-noir-game - Summary: A voice-only detective game on LiveKit, Deepgram, NVIDIA NIM and ElevenLabs, where the whole interface is a conversation and there is nothing to click. - Published: 2026-07-19T00:00:00.000Z - Topics: Voice AI, LiveKit, Deepgram, ElevenLabs, Game The Noir Game, or The Interrogation Room, is a voice-only detective story. You are the detective, the suspect is across the table, and you have to talk your way to a confession before they lawyer up. I built it because every voice agent I had worked on was an assistant. I wanted to hear one that had a motive instead of a task. A character is a harder target than an assistant. An assistant may pause, apologize and ask a clarifying question; a suspect who does that stops being a suspect. The conversation has to keep moving, hold a secret, and change temperature when the player finds a weak point. That puts the load on turn-taking and on voice, not on the wording of an answer. ## Lies with triggers The case is authored, not generated. `livekit-agents/case_content.py` holds Victor Crane, the Halbrook file and a set of planted lies. Each lie carries four things: the truth he knows, the cover story he tells, the tells a detective might notice, and the specific trigger that forces it to crack. `suspect.py` folds those into the system prompt alongside four states. Calm is small talk. Defensive is the detective circling something true. Cracking is a contradiction caught. Breaking is the dam going. A confession requires the breaking state, at least three distinct lies caught, and sustained final pressure. Accuse him wildly instead and he asks for his lawyer, which is the other ending. ## Three processes The runtime splits into three processes so each concern stays legible. `backend/main.py` is a FastAPI server with two endpoints. `/api/case` returns the file the player reads first, and `/api/token` mints a LiveKit JWT. That token carries a `RoomAgentDispatch` for the agent named `suspect`, so joining the room is what summons him. `livekit-agents/agent.py` is the worker. Deepgram nova-3 transcribes, Kimi K2.5 answers over the OpenAI-compatible NVIDIA NIM endpoint, and ElevenLabs `eleven_flash_v2_5` speaks. Silero VAD and the multilingual turn detector are pulled locally by `python agent.py download-files`, so end of turn is decided next to the audio. A Next.js 16 frontend presents the room. ## The signal protocol Every model turn has to be one JSON object, and `signal_protocol.py` is that contract. The fields are `speech`, `state`, `stress_level`, `contradiction_caught`, `contradiction_topic`, `ending` and `internal_note`. The agent splits the object. Only `speech` reaches text to speech, and the chat history is rewritten to that same text, so the model does not start imitating its own JSON wrapper. Everything else becomes LiveKit participant attributes. `web/lib/signals.ts` reads them to color the transcript, tag a caught contradiction and raise the ending screen. Each state also maps to an ElevenLabs voice profile. Calm sits at stability 0.75 and style 0.20, and breaking drops to 0.15 and 0.85, so the voice loosens as he does. ## Decisions and costs - Put the game state in the spoken turn rather than in a tool call. One object per turn keeps the line and the state consistent by construction. The price is a model that must never wrap its answer in prose, so `parse_turn` falls back to treating the whole response as speech. - Author the lies instead of generating a mystery. A hand-written crack trigger makes the game winnable and repeatable. It also means one case, and replay value ends once the player knows the trigger. - Shift the voice with the state. Calling `update_options` mid-session is the cheapest way to make pressure audible. It sits inside a try block because not every plugin version exposes it, which is the decision I would revisit. ## Running it locally Copy `.env.example` to `.env` and fill in `LIVEKIT_*`, `DEEPGRAM_API_KEY`, `ELEVENLABS_*` and `NVIDIA_NIM_API_KEY`. The download runs once. The last three commands each want their own terminal. ```bash cd livekit-agents && python agent.py download-files cd backend && uvicorn main:app --reload --port 8000 cd livekit-agents && python agent.py dev cd web && npm run dev ``` Open `http://localhost:3000`, read the case file, and start talking. A crack tag appears the moment `contradiction_caught` arrives on the attributes, and the ending screen appears when `ending` is set. ## Limits and license One case ships today, and there is no persistence, no score history and no deployment target beyond the three local processes. CORS on the backend is pinned to `http://localhost:3000`. Kimi is asked for JSON on every turn at temperature 0.85, so the tolerant parser earns its place. Every provider is an external paid service, though NVIDIA NIM is free for prototyping. The source is MIT licensed, and LiveKit, Deepgram, NVIDIA NIM and ElevenLabs remain under their own terms. --- ### Perch - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/perch - Summary: A private, local-first browser extension for organizing, stashing and restoring tab groups, built with WXT and React. No account, no server, no telemetry. - Published: 2026-07-18T00:00:00.000Z - Topics: Browser Extension, TypeScript, React, WXT, Local-first Perch replaces the new-tab page with a dashboard of every tab I have open, grouped by domain and sorted by how busy each group is. It is a browser extension built with WXT, React and TypeScript. There is no server and no account, and the concept came from Zara’s tab-out. The reason I kept tabs open was never that I needed them on screen. It was that closing one felt like losing it. A bookmark is a different place that I never go back to, so the tab stayed instead. Listing tabs prettily does not fix that. Making closing safe does. ## Making closing safe A stash is a named group of tabs, saved and then closed for real. It reopens in one click, or into a new window. An undo-close stack catches the ones I did not mean to close. Saved for later is a per-tab checklist with a collapsible, searchable archive. Tabs drag onto an existing stash, onto a new-stash dropzone, or into Saved for later, and stashes drag to reorder. A right-click menu offers Stash and Save for later without opening the dashboard at all. A command palette on ⌘K, or `/`, fuzzy searches open tabs, saved tabs and stashes, then jumps, opens or restores by keyboard. ## Grouping is the work Everything in `utils/grouping.ts` is pure, with no browser API inside it, which is exactly why it is the part Vitest covers. Tabs bucket by domain and sort by count. `isLandingPage` pulls homepages into a single card, so an inbox tab can go without disturbing a deep link underneath it. `file://` tabs collect under Local Files, and `localhost` tabs show their port so dev projects stay distinguishable. `matchCustomGroup` merges subdomains or splits a site by path, driven by `CUSTOM_GROUPS` in `utils/config.ts` beside `FRIENDLY_DOMAINS` and `LANDING_PAGE_PATTERNS`. Titles lose their notification counts and trailing suffixes, and get synthesized from the URL for repositories, issues, posts and videos. ## Sync without a server `utils/syncedStore.ts` is the piece I am happiest with. `storage.local` stays the always-available source of truth, and the same list mirrors into `storage.sync` so stashes follow the browser profile between a person’s own devices. No account and no backend appear anywhere in that path. `storage.sync` allows roughly 8 KB per item and 100 KB overall, so the list is JSON encoded and split across numbered chunk keys. New chunks are written before stale ones are pruned, so the meta record never points at a missing chunk. Both areas carry a monotonic `rev`, and a load takes whichever copy is newer. A write that exceeds quota is caught and skipped, and local still holds everything. ## Decisions and costs - Make closing safe rather than make tabs pretty. Stashes are the whole answer to why tabs accumulate. They also add a second store that has to stay coherent with the live tab list. - Keep grouping and formatting pure. Neither `utils/grouping.ts` nor `utils/format.ts` touches a browser API, so both are unit tested directly. What is left untested is a thin layer of typed wrappers over the tabs and storage APIs. - Mirror into `storage.sync` instead of running a sync service. It costs nothing and needs no login. It is also quota bound, so a large collection quietly stays on one device, and that is the decision I would revisit. - Use Web Locks around every read, modify and write. Two dashboard tabs can mutate the same list at once, and an atomic mutation was cheaper than reconciling a conflict later. ## Permissions and limits The manifest asks for `tabs`, `storage`, `tabGroups`, `alarms` and `contextMenus`, and nothing else. `alarms` drives an optional hourly auto-stash of stale tabs, off by default. Perch surfaces tabs untouched for seven days and suspends inactive ones with an estimate of the memory freed. It also flags duplicate URLs for a one-click close, reflects Chrome’s native tab groups, and updates live as tabs change. There is no analytics and no telemetry, and Inter ships bundled rather than fetched. The one network call loads favicons from Google’s public favicon service. Everything exports to a local JSON file, and uninstalling removes it all. `pnpm test` is a typecheck plus Vitest, and the build targets Chrome and Firefox under MIT. --- ### Telephony - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/telephony - Summary: A provider-agnostic NestJS backend for SMS, voice, verification, lookup, phone numbers and A2P 10DLC. Product code talks to a capability, not a vendor SDK. - Published: 2026-07-14T00:00:00.000Z - Topics: NestJS, TypeScript, Twilio, Voice, SMS Telephony is a NestJS backend that gives an application one API over its communications providers. SMS, voice, Verify, Lookup, phone numbers, messaging services and conversations each get a provider-neutral interface and controller. Before it, product code imported a vendor SDK directly, which meant the vendor was named in every feature that sent a message. The obvious answer, wrapping the SDK in a service class, only moves the coupling one file deeper. The provider still leaks through its response shapes and its error codes, and switching one capability means touching everything that touched that service. I wanted the provider to be a runtime setting rather than an import. ## Capabilities not vendors Each capability defines its own interface, and adapters register against it. Environment configuration chooses the provider separately for every capability, and a registry resolves that choice at request time. One deployment can therefore route voice through whichever carrier sounds best into a market, SMS through whoever holds the A2P registration, and verification through a third provider. Resolving per request is not automatic health-based failover, and I try not to describe it as one. The pattern came from [running Twilio, Vonage and Telnyx behind one interface](/blogs/one-api-many-carriers-provider-neutral-telephony) for a set of voice agents, then distilling what survived into this service. ## Where portability stops The classic way a provider abstraction fails is designing the interface around what every vendor has in common. You then cannot use the reasons you picked a vendor in the first place. A2P 10DLC registration, TrustHub, TaskRouter and subaccounts have provider-specific lifecycles that do not compress into a generic send method. So the layer is two tiers on purpose. The capability tier covers traffic that is genuinely portable, which is most of it. Alongside it, Twilio keeps first-class namespaced routes for A2P 10DLC, TaskRouter, TrustHub, Studio, Proxy, Fax, short codes, subaccounts, credentials, recordings, usage and webhooks. The dependency stays visible in one place rather than smeared through the codebase. ## The inbound half Sending through many providers is the easy direction. Every SDK send call maps onto a capability method. The hard direction is everything providers send back: message status webhooks, inbound SMS and call events, each with its own payload shape, retry behavior and signature scheme. Signatures are verified at the edge, using the exact URL, body or parsed parameters that scheme requires, before any business logic runs. Handlers assume duplicate and out-of-order delivery, persist the event before acknowledging it, and reconcile missing terminal states afterward. Status vocabularies are mapped explicitly, with the raw provider status kept beside the normalized event. An unfamiliar value becomes an explicit unknown state that alerts, never a silent default. ## An ambiguous send The dangerous call is the outbound request whose response never arrives. The carrier may already have accepted it. Retrying through a second provider then creates two real calls for one product action. So every product action carries a stable operation ID, and its provider, attempt and provider reference are persisted. Four outcomes are distinguished. An unsupported capability is rejected before any provider request. A confirmed rejection may fall through to an eligible alternative. An accepted operation is tracked through its lifecycle. Acceptance unknown is reconciled through status queries or callbacks before anything is issued again. A shared method name is easy. A shared meaning for “did we place the call?” is the part that took the work. ## Decisions and costs - Two tiers rather than a lowest common denominator. Provider-specific routes stay first class, which means the layer does not promise portability it cannot deliver. The cost is that a workflow can quietly become Twilio-only. - One place for policy. Banned-number and do-not-call filtering, quiet hours and per-tenant rate limits live here rather than in each feature, which is also where [calling-reputation hygiene](/blogs/stir-shaken-cnam-and-why-nobody-answers-your-ai-calls) belongs. - Ship stubs, not adapters. Vonage, Plivo and MessageBird exist as explicit stubs so an unimplemented capability is a stated fact rather than a runtime surprise. What I would revisit is shipping only one complete adapter, because a second real implementation is the only honest test of an abstraction. ## Limits and status Twilio is the one complete adapter today. Adding another means implementing the capability interfaces I care about and registering them, while the public routes and the product workflows above them stay where they are. Failover is bounded by reality rather than by code. The alternate provider still needs suitable numbers, credentials, capacity and registrations. Configuration cannot move an active call, port an inbound number instantly or transfer a campaign approval. With one provider, one use case and no volume, this whole layer is ceremony. It starts paying rent at the second provider, the second region, or the first outage. --- ### Conversational Voice Agents - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/conversational-voice-agents - Summary: A provider-neutral control plane with interchangeable LiveKit and Pipecat workers for production voice agents. - Published: 2026-06-22T00:00:00.000Z - Topics: Voice AI, LiveKit, Pipecat, Python, Telephony, OpenTelemetry Conversational Voice Agents is the platform I built for running voice agents on more than one real-time engine. A control plane owns tenant configuration and call state, and stateless workers place and answer the calls. Before it, product configuration, business logic and observability all lived inside whichever runtime the agent happened to use. Voice runtimes change quickly, and the obvious answer, picking one SDK and building on it, makes the next migration expensive. I wanted the choice of engine to be a deployment decision rather than a rewrite. So I moved the contract upward: the control plane describes what a call should do, and the worker decides how to do it. ## Why a contract The first version ran on a managed platform. At zero agents in production that was the right trade. Media transport, telephony, model orchestration and interruption handling all arrived on day one, for a per-minute fee. What ran out was reach. The incidents that matter are never “the call failed”. They are “the agent went quiet for two seconds and the caller hung up”. Answering that needs per-stage latency and the ability to change the execution path. So I wrote my own contract a year early, while still managed. The [move to LiveKit](/blogs/migrating-production-voice-agents-from-vapi-to-livekit) then added a second worker instead of starting a rewrite. ## Inside one call Every call begins as a typed runtime profile. It names providers and models, prompts, tools, transport, recording, voicemail behavior, idle handling, duration limits, transfers and reporting. Unknown providers fail explicitly instead of falling back in silence. The control plane hands that profile to a worker and keeps the call state. The LiveKit worker and the Pipecat worker share generated wire contracts, not engine internals, so each keeps its native pipeline and lifecycle. Lifecycle events travel back idempotent and HMAC signed. OpenTelemetry spans cover the real-time pipeline, so one call looks the same in my tooling whatever executed it. The engine stays fixed for the life of a call. ## Decisions and costs - Write the contract before needing it. Building runtime profiles while still on the managed platform bought nothing visible for months. It is the only reason the engine swap moved traffic by profile rather than by flag day. - Own the phone system. The model pipeline was not the hard part. SIP trunking, [caller identity and attestation](/blogs/stir-shaken-cnam-and-why-nobody-answers-your-ai-calls), detection and transfers were. Infrastructure cost fell [from $0.26 to $0.10 a minute](/blogs/the-real-cost-of-a-voice-agent-minute), and the engineering to operate it is not inside that figure. - Keep two workers alive. Every contract change now has to cross both. In return, a new runtime can be graded against the same profiles before any traffic reaches it. - Detect answering machines asynchronously. Waiting for a verdict adds dead air for real people, so the call proceeds while detection runs. The cost is that the verdict can land after the agent has started speaking, and every consumer has to re-check call state first. That is the one I would revisit. ## When nobody answers Take an outbound follow-up. The dial connects, and detection runs while the opening line is still queued. A verdict of `machine` routes the call to the voicemail behavior in its profile. That is either a hang-up and a retry on a backoff, or a message written for a mailbox, logged so the next touchpoint does not repeat it. A verdict of `unknown` stays its own result with a bounded fallback, never quietly rewritten to `machine`. The failure that matters is the other direction. A person picks up, says hello twice, and a late callback ends the call. It surfaces as a short call whose transcript and end reason disagree. So [false-human and false-machine rates](/blogs/answering-machine-detection-is-turn-takings-evil-twin) are tracked separately, with unknown rate and decision time beside them. ## Limits and status Today the platform runs inbound, outbound and web calls with transport-specific routing. It models recording, cold transfers and the boundaries of a warm transfer. Reported metrics cover model usage, latency, transcripts, end reasons and failure context, keyed by call, tenant, use case, engine and model. [Turn-taking](/blogs/turn-taking-is-the-hardest-problem-in-voice-ai) is the standing open problem rather than a solved one. Endpointing, barge-in and backchannel handling get tuned against a real call distribution. The implementation is maintained in private repositories, so this page describes the architecture and the decisions rather than the source. --- ### Infra Lab - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/infra-lab - Summary: Ready-to-use Docker Compose environments for databases, queues, search, observability, caches, and vector stores. - Published: 2026-06-14T00:00:00.000Z - Topics: Docker, Infrastructure, Databases, Observability Infra Lab is a repository of Docker Compose setups I keep so that a local service is one command away. Each tool gets its own directory with a `docker-compose.yml` and a short README describing ports and credentials. It exists because I kept writing the same compose file from memory whenever a project needed a database to talk to. The alternative most people reach for is one large stack that starts everything at once. That works until you want a single Redis and end up waiting on Kafka. Or until a version pin in the shared file breaks a project that was fine yesterday. A throwaway environment should not carry the weight of the environments next to it. ## One directory per tool Directories are grouped by role rather than by project: `caches/`, `ci-cd/`, `databases/`, `messaging-queues/`, `observability/`, `reverse-proxies/`, `search-engines/` and `vector-databases/`. Inside each sits one directory per tool, holding a compose file and a README. So `databases/postgresql`, `messaging-queues/kafka`, `observability/grafana-prometheus`, `search-engines/typesense` and `vector-databases/qdrant`. Nothing is shared between them. There is no root compose file, no common network and no `.env` at the top of the tree. The README beside a compose file carries the ports and the credentials, which is why the compose file is never the only documentation. ## Inside a compose file Every image tag is an exact version rather than `latest`: `postgres:18.4`, `typesense/typesense:30.2`, `grafana/grafana:13.0.2`, `confluentinc/cp-kafka:8.2.1`. Several stacks bring a companion UI along, so PostgreSQL starts pgAdmin and Redis starts RedisInsight beside the service itself. Kafka runs in KRaft mode with no ZooKeeper, advertising two listeners so that a container reaches the broker at `kafka:29092` while the host reaches it at `localhost:9092`. The PostgreSQL file carries a comment worth keeping. Postgres 18 stores data in a versioned subdirectory, so the volume has to mount the parent path or the container never starts. | Service | Host ports | | ------------------------- | ---------- | | PostgreSQL with pgAdmin | 5432, 8432 | | Redis with RedisInsight | 6379, 8001 | | Kafka broker and JMX | 9092, 9101 | | Grafana with Prometheus | 3000, 9090 | | Typesense and Meilisearch | 8108, 7700 | | Qdrant and ChromaDB | 6333, 8000 | ## Choices and their cost - One tool per directory, with nothing shared. Starting a database never starts a queue, and a bad pin in one file cannot reach another. The cost is that two of these cannot talk to each other without manual network work, since there is no shared compose project to join. - Tags are pinned rather than floating. A setup that worked last month still works today, and an upgrade is a visible edit. The cost is that pins go stale, and refreshing them is manual across the whole catalog. - Credentials sit in plain text in the compose file and again in the README. For a throwaway environment that is the point, because a password you have to go and look up defeats the purpose. It also means nothing here belongs on a shared host. - Data lives in named volumes, so `docker compose down` keeps it and `down -v` removes it. Typesense is the exception. It bind mounts `./data` into the repository directory, which is the inconsistency I would fix first. ## Bringing one up ```bash cd databases/postgresql && docker compose up -d docker exec -it PostgresCont psql -U postgres curl -sI localhost:8432 | head -1 cd ../../search-engines/typesense && docker compose up -d curl -H "X-TYPESENSE-API-KEY: xyz" localhost:8108/health cd ../../messaging-queues/kafka && docker compose -f docker-compose.yaml up -d docker compose down -v ``` `docker compose up -d` is the entire interface, and stopping a service leaves nothing behind. Two details catch people out. In pgAdmin the server host is `postgres_db` rather than localhost, because the two containers share the compose network and talk by service name. Kafka needs the explicit `-f`, because its file is named `docker-compose.yaml` while most of the catalog uses `.yml`. ## Rough edges Naming is not uniform. Kafka and ChromaDB use `docker-compose.yaml`, the ChromaDB directory is spelled `chrome_db`, and its README is `Readme.md`. Only ChromaDB and Nginx Proxy Manager declare a healthcheck. Ports collide if you run neighbours together, since MySQL and MariaDB both publish 3306, and ClickHouse and SonarQube both want 9000. The RedisInsight container carries a placeholder password rather than the real Redis one. There are no resource limits and no TLS anywhere, because none of this is meant to be exposed. Adding a tool means adding a directory, which is why the catalog can grow without any of it becoming load bearing. It is MIT licensed. --- ### FastAPI Boilerplate - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/fastapi-boilerplate - Summary: A structured FastAPI starter with Poetry, Docker Compose, consistent response conventions and optional LangChain helpers. The layout I stopped rewriting. - Published: 2026-05-09T00:00:00.000Z - Topics: Python, FastAPI, Docker, LangChain FastAPI Boilerplate is the project layout I stopped rewriting. It is a minimal starter with Poetry, Docker Compose, and directories already carved out for routers, middleware, models, dependencies and database wiring. I scaffolded it for a quiz-master AI backend and then kept reaching for it on the next thing. Starting a Python API means remaking decisions I had already made. Where do routers live, how is config loaded, what shape does a response body have, does it run in Docker. None of those are interesting to redo, and getting them slightly different in every service is worse than getting them wrong the same way everywhere. ## The response envelope This is the piece I care about most. `app/models/response.py` defines a `ResponseModel` with six fields: `status_code`, `message`, `data`, `error_code`, `timestamp` and `success`. Its `create_response` classmethod stamps the timestamp itself, so no handler assembles metadata by hand. `app/utils/create_response.py` is a thin function over that classmethod, which is what routes actually import. A client then parses one shape across every endpoint, failures included, where `success` is false and `error_code` carries the reason. Six fields is small enough that nobody argues about the seventh. ## The tree it gives `app/` holds `core/` for config, `db/` for database wiring, plus `routers/`, `middlewares/`, `models/`, `internal/` and a `dependencies.py`. Most of those are empty by design. The template names a place for each concern rather than filling it with a domain it does not have. `app/main.py` is four lines and creates the FastAPI application, nothing more. The part that carries code is `utils/`, which sits outside `app/`. It holds a `setup_logger` that formats with file and line number, a LangChain initialiser for Ollama, and Ollama helpers for chat, embeddings and model management. Poetry pins Python 3.12, Pydantic v2 and Uvicorn. ## Choices and their cost - One response envelope, used everywhere. A client parses a single shape, and no route rebuilds the metadata. The cost is that a handler returning a raw model bypasses the convention silently, because nothing enforces it. - Directories are carved out and left empty. A new service inherits a place for every concern without inheriting someone else’s domain. The cost is real: `app/core/config.py` is a blank file, so settings loading is still the first thing every project writes. - The AI helpers live in `utils/`, outside the app tree, so a plain CRUD service never imports them. Installing is another matter. `torch`, `ollama`, `langchain` and `openai` are main dependencies rather than an optional extras group, so `poetry install` pulls PyTorch for an API that will never touch it. Moving them behind a Poetry extra is the change I would make first. - It stays small deliberately. I extend it per project instead of growing the template, because a template that grows turns into a framework nobody reads. ## Starting from it ```bash poetry install poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload curl -s localhost:8000/openapi.json | jq -r '.info.title' ``` The API answers on port 8000 with an empty OpenAPI document, because `routers/` starts out with nothing in it. The first real edit is a router module included from `app/main.py`, whose handlers return through the envelope: ```python from app.utils.create_response import create_response @router.get("/health") def health(): return create_response(status_code=200, message="ok", data={"status": "healthy"}) ``` ## Rough edges and status The Docker path does not work as written. `docker-compose.yaml` builds from a `Dockerfile` that is not in the repository, and its command points at `app.main.main:app` rather than `app.main:app`. `tests/` exists with pytest declared as a dev dependency and no test files inside it yet. The Poetry path is the one I use and the one that works. It is open source under MIT, and I keep it as a starting point rather than a maintained product. --- ### Fill Data Figma Plugin - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/fill-data-figma-plugin - Summary: A Figma plugin that fills selected vehicle-listing layers from live Google Sheets inventory, replacing copy-paste across dozens of near-identical mockups. - Published: 2026-05-09T00:00:00.000Z - Topics: Figma, TypeScript, Automation, Google Sheets Fill Data is a Figma plugin that populates vehicle listing mockups from a public Google Sheet. A designer selects the layers to fill, picks a field or an image angle, and the plugin writes real inventory values into that selection. I built it to replace filling that content in by hand, card by card. It is one file of plugin code and one HTML pane. The manifest asks for three network domains and nothing else. Everything the plugin does happens against whatever is selected at that moment. ## Why real content Listing layouts only reveal their problems under real content. Real names run long, real prices vary in width, real photos arrive with the wrong crop. Placeholder text hides all of it. Filling a dozen near-identical cards by hand takes long enough that nobody redoes it after a layout change. That is precisely the moment the check matters most. Inventory data already lives in spreadsheets, so a designer can point the plugin at one and edit it without filing a request against anybody. ## Filling by selection There is no layer naming convention to learn. `figma.currentPage.selection` is the whole input. For a text fill the plugin keeps the `TEXT` nodes in the selection and writes the chosen field into each one, walking a different vehicle per node. Twelve selected layers become one field across twelve consecutive rows, and `currentIndex` advances so the next run continues down the list. The field can be a plain column such as `price` or `mileage`, or one of two composed values. `fullname` joins year, make, model and trim, and `full_specs` builds a four line block. `updateTextNode` loads the node’s existing font first, falls back to Inter, Roboto or Arial, and restores size, fills, alignment and auto-resize after writing. ## Images into frames An image fill accepts `FRAME` and `RECTANGLE` nodes and takes an angle, a processing mode and an aspect ratio. Each sheet row carries eight exterior URLs, at 0, 45, 90 degrees and so on around the car, plus an interior shot. Choosing raw switches to the matching `_input` column, which holds the unprocessed photograph. A selected rectangle is replaced by a frame in the same place, resized to 16:9, 4:3 or 1:1 around its center, and painted grey under a temporary `Loading image...` label. The bytes arrive from the UI pane, `figma.createImage` turns them into a fill, and each step revalidates the frame through `figma.getNodeByIdAsync` because a designer can delete it mid-fetch. ## Choices and their edges - A public Google Sheet as the source, read through the gviz CSV endpoint. No credentials, no API key and no backend to run. Anyone holding the sheet link holds the data, so this only suits inventory that is already not secret. - The plugin sandbox cannot reach the network, so every fetch runs in the UI iframe and returns over `postMessage`. A CORS proxy sits in the manifest allowlist to make that work. It is a third party in the path of the sheet contents, and it is what I would replace first. - Working from the current selection rather than the document keeps the action scoped and legible. A designer never hands an automation blanket access to a file. It also means the plugin cannot fill a whole page in one command. - One field per run, spread across the selection. That is predictable, and it composes. A complete card still takes one run per field, which is the price of not inventing a naming convention. ## Running the plugin `npm run build` compiles `code.ts` into `code.js` with `tsc`, and `npm run watch` keeps it compiling. Figma imports it from the manifest under Plugins, then Development. On first open the plugin reads `sheetConfig` out of `figma.clientStorage`, and with no sheet id it says so rather than failing quietly. Give it a sheet id and a tab name, and it requests that sheet as CSV from the `gviz/tq` endpoint, parses the rows and reports how many vehicles loaded. Selecting twelve text layers and clicking a field then fills all twelve. ## What is missing The README is still the Figma plugin template’s own setup notes rather than anything about this plugin. `package.json` carries an empty `license` field while an MIT `LICENSE` file sits beside it. A sample CSV is committed to show the expected columns. The manifest declares `documentAccess` as `dynamic-page` and allows three domains: the Google Sheets host, the CORS proxy and the media host the image URLs point at. Nothing calls `figma.commitUndo`, so undoing a twelve layer fill is twelve separate undos. There is no test, and no way to preview a fill before it lands. --- ### Google Reviews Scraper - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/google-reviews-scraping - Summary: A free, resumable Google Maps review scraper on Playwright and SQLite, with CSV and JSON export and checkpointing so an interrupted run resumes cleanly. - Published: 2026-05-09T00:00:00.000Z - Topics: Python, Playwright, SQLite, Web Scraping grscraper pulls the reviews for a list of businesses off the Google Maps surface, stores them in SQLite, and exports to CSV, JSON or NDJSON. Inputs can be plain business names, Maps URLs or place IDs, mixed in any order. It runs as a CLI on my own machine, or as a container on Cloud Run, Lambda or Kubernetes. Long scrapes fail. The process gets killed, the network drops, or Google serves its sorry page. A scraper that keeps its state in memory loses hours of work, and rerunning it duplicates everything it already had. Both of those are problems about state, not about parsing HTML. ## Why the Maps surface The scraper targets Maps rather than Search, which is far more permissive about automated access. The paid alternative is a reviews API, and for one list of businesses that is a subscription bought for an afternoon of work. The other alternative is the Business Profile API, which only helps if you own the listing. What was left was a scraper a person can run for free and leave running. That framing is what pushed the design toward state and away from parsing. ## What SQLite holds `grscraper init` creates `data/scraper.db` with three tables. `businesses` carries the input value, its classified type, the resolved place metadata, and a `status` beside `retry_count` and `status_reason`. A `UNIQUE (place_fingerprint)` constraint means the same place ingested twice, once by name and once by URL, lands once. `reviews` uses Google’s `data-review-id` as its primary key, so a rerun writes nothing when nothing changed. Each row keeps the reviewer, their counts, the rating, the relative date, the text, photo URLs, any owner reply, and the scraper version that produced it. `scrape_runs` records each run with its worker count and totals. Because status lives on the row, a crash resumes where it stopped. ## Getting the page open `browser.py` drives Playwright Chromium and `selectors.py` is the single chokepoint for Google’s DOM. Every class name lives there: `div.jftiEf[data-review-id]` for a review card, `.wiI7pd` for its text, `.rsqaWe` for the relative date, `h1.DUwDvf` for the place title. When Google rotates classes there is exactly one file to fix. The reviews pane opens through its tab, then scrolls with a randomised pause of 1500 to 2500 milliseconds. Google often withholds that pane from automated clients without ever showing a CAPTCHA, so `stealth.py` masks the usual signals. `GRS_BROWSER_CHANNEL`, `GRS_HEADED` and `GRS_TIMEZONE` exist so a deployment can test what actually helps. ## Four calls I made - Stop on the block page rather than retry. Finding `/sorry/index` in the URL halts the run, exits with code 2 and marks the business `blocked`. Pushing harder while blocked makes the block worse. One bad target also ends a batch that might have finished. - SQLite instead of a queue or a database service. It is a file, it survives a crash, and a container only has to mount `/data`. Writes stay single threaded, which is why raising `--workers` is not really the point. - Two runtime dependencies, Playwright and BeautifulSoup. The S3 and GCS sinks import their SDKs lazily and declare them as extras, so the base image stays small. Anyone using those sinks has to remember the extra. - No pinned user agent by default. A pinned string goes stale as the bundled browser updates, and an agent string that disagrees with the engine is a fingerprinting signal of its own. `GRS_USER_AGENT` is there for whoever disagrees. ## A full run Five commands take a name to a CSV. ```bash grscraper init echo "value" > inputs/test.csv && echo "acme coffee roasters" >> inputs/test.csv grscraper ingest inputs/test.csv grscraper -v run grscraper export reviews --format csv ``` The export lands in `data/exports/` with a UTC timestamp in the filename. `grscraper sync` collapses those steps into one idempotent command and hands the result to every configured sink, which is what the container runs by default. A sink is a URI: stdout, a file path, an HTTPS endpoint, `s3://` or `gs://`. Exit codes carry the outcome, where 0 is clean, 2 is a CAPTCHA and 3 means the scrape worked and a sink did not. ## Out of scope Proxy rotation, distributed workers and mid-business resume are all deliberately absent. Every run re-scrapes a business in full, and deduplication makes that a no-op rather than an incremental fetch. Two things consumers get wrong. The Maps surface carries no absolute review dates, only relative ones, and the advertised `review_count` is not the number of reviews you actually collected. Tests run under pytest and ruff on Python 3.11, 3.12 and 3.13 in CI. The suite is offline by design: fixture HTML, a temporary SQLite file and a loopback server, with no browser installed. It is MIT licensed. --- ### Reckon - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/reckon - Summary: A read-only agent workspace for investigating production incidents across observability, CI/CD, cloud, databases, and source control. - Published: 2026-03-29T00:00:00.000Z - Topics: Agentic AI, SRE, Observability, Go, DevOps, RCA Reckon is a workspace a coding agent opens to investigate an incident. It wires fourteen read-only observability, CI/CD, queue, Kubernetes and database CLIs into one isolated credential environment. Traces, builds, cloud metrics, Kafka lag and database state can then be correlated in a single session. Before it, I handed the agent a shell and my own credentials and watched carefully. The hard part is not another agent framework. It is the environment: which systems the agent can reach, where its credentials live, where writes are refused, and where the operator should feel friction. Unrestricted shell access is not a safety model, it is the absence of one. ## One clone per environment `.envrc` resolves `RECKON_ENV`, then pins `XDG_CONFIG_HOME` to `.config//` inside the clone. It loads `.env.common`, then `.env.`, then `.env..local`, with later files winning. Precedence runs from an exported `RECKON_ENV`, to the `.reckon-env` file that `reckon use` writes, to production. An unrecognized value fails closed. No credentials load at all, rather than quietly selecting something real. Grafana, Jenkins, CubeAPM and `gh` honor `XDG_CONFIG_HOME` by themselves. The AWS CLI does not, so `.envrc` also pins `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE`, and points `KUBECONFIG` inside the clone. Production and staging sessions never share saved profiles. ## Layers that actually deny Database safety is three layers, and only one denies a write across every access path. Layer one is a true read-only role at the database, which the operator has to provision. Layer two hardens the clients only. `PGOPTIONS` carries `default_transaction_read_only=on`, and MySQL gets `init-command=SET SESSION TRANSACTION READ ONLY` in a workspace-local option file, since that client has no environment variable for it. Layer three is friction: the Claude Code allowlist deliberately omits `psql`, `mysql` and `mongosh`, so every query asks. Kafka mirrors the same shape. The principal carries only describe and read ACLs, and consuming is documented group-less, because `kcat -G` joins a production consumer group and commits offsets. ## The control surface `scripts/reckon` makes the workspace state legible, so neither the operator nor the agent has to guess. `status` prints the active environment and an integration matrix. `doctor` diagnoses setup problems, including the silent one. If direnv is not hooked into the shell, `.envrc` never runs and the CLIs fall back to saved profiles with no warning. `preflight` is the digest an agent reads before its first query, and it touches no infrastructure. `verify` is the only command that does. Values left at their `.env.example` defaults are reported as unconfigured, so the matrix does not flatter the setup. Without direnv, `eval "$(./scripts/reckon env)"` activates a shell. ## Investigating an incident [Claude Code](/blogs/claude-code) reads `CLAUDE.md` at the start of a session, and `AGENTS.md` carries the same contract to Codex CLI and OpenCode. Work routes by what is known. `investigate` takes a specific bad thing in a specific window. `monitor` sweeps when nothing is known to be broken. `analyze` covers capacity, cost and profiling with no fault at all. An investigation starts broad, at alerts and failed builds, then traces and logs, then metrics, deployments and dependencies. Queues, Kubernetes, caches and databases come last, and only where evidence points. Output lands in `incidents/-/` beside its raw evidence. Each RCA carries a root cause category from a controlled vocabulary, a confidence score, and a split between what tools proved and what stays inference. ## Decisions and costs - Wire existing CLIs rather than ship one binary. Upstream flags keep working and nothing is wrapped in a worse interface. The cost is fourteen install paths to keep current across macOS, two Linux families and WSL2. - Give the custom Go CLIs an agent shape. `grafana`, `jenkins` and `cubeapm` return JSON under `-o json`, keep stdout and stderr separate, and read their configuration from the clone. - Keep a human in the loop. Approval prompts are a real barrier today, which also means this safety model does not survive automation. That is the decision I would revisit, and it is exactly why the hosted edition is deferred. - Leave direnv optional. It is convenient and it fails silently, so `doctor` had to be written to catch it. ## Limits and editions The workspace edition is the only one being built. `EDITIONS.md` documents a hosted edition and argues for not starting it. Removing the human removes the approval layer, so every guarantee has to move into enforced credentials and a durable audit trail. The methodology in `skills/reckon/` is the shared core, while `infra-knowledge/` and `incidents/` are gitignored wholesale, so no service inventory or incident history ships with the repository. Native Windows is partial, since direnv, `kcat`, `rpk` and `redis-cli` have no clean port. The workspace runs from a clone on a laptop and is meant to hold production credentials only. --- ### Movie Ticket Booking Webapp - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/movie-ticket-booking-webapp - Summary: A hackathon movie booking app on Next.js and PostgreSQL, with a TMDB catalog, trailers, showtimes and a seat picker. Payments were out of scope. - Published: 2024-08-16T00:00:00.000Z - Topics: Next.js, PostgreSQL, Docker, AWS, Hackathon QuickNix is a movie booking web app built for the StarHack hackathon. It lists what is showing using the TMDB API and lets you search it. A movie page carries the poster, the overview, the vote average and a theater and showtime picker. It is a hackathon build, and it stopped where the clock did. The README says as much in its own words. Payments and the reservation system were never finished. Everything up to checkout works, and I would rather leave that written down than imply a flow that is not there. ## How the catalog fills An administrator pastes an IMDb id into the add-movie form, and `actions/tmdb.js` calls the TMDB `find` endpoint with `external_source=imdb_id`. The first match is flattened into the shape of the `movies` table, with poster and backdrop paths rewritten onto the `w500` image host. `GET /api/v1/movies` then reads that table through Drizzle, pages the result, sorts by `releaseDate` and matches titles with `ilike`. It also excludes rows whose poster path resolved to the literal `/None`, which is what TMDB hands back for a title with no artwork. The same route mirrors documents into a Typesense `movies` collection faceted on genre, language and vote average. ## The seat picker Choosing a seat is the part of a ticket flow anyone actually remembers, and it is the least real part here. `components/app/buyTickets/SeatSelection.jsx` renders from a `seatData` constant compiled into the component. Premium rows L, K and J cost 300 rupees, executive rows I down to C cost 280, and rows B and A cost 260. Selection is local `useState` keyed by a `section-rowNumber` string, and the total is a reduce over the selected ids. The `theaters` table does carry a `seats` JSONB column, defaulted to an empty array. Nothing reads it. The map on screen belongs to no theater in the database. ## Five tables `lib/db/schema/` holds `movies`, `theaters`, `showtimes`, `reservation` and `user`, all Drizzle over Postgres on Neon. A showtime joins a movie to a theater and carries `ticketPrice`, `startTime` and `endTime`. A reservation holds its showtime, its user, seats as JSONB, a unique `orderId`, a total, customer name and phone, and a status of pending, confirmed or cancelled. Theaters store both `address` and `seats` as JSONB. Keeping seats in JSONB on two tables is the decision I would revisit first. It made a seat map easy to imagine under hackathon pressure, and it makes an honest availability query awkward. ## Choices under a clock - Server actions call the application's own REST routes over HTTP through `NEXT_PUBLIC_APP_URL` instead of reading the database. One access shape then serves the pages and the Bruno collection in `bruno/` alike. Every page load also pays a network hop back to itself. - Typesense sits beside Postgres rather than replacing the `ilike` query. Facets on genre and language become cheap. Two stores now have to stay in step, and only the write path in `/api/v1/movies` does that. - Authentication is Auth.js, and `middleware.js` is one re-export of it. Sign-in, sign-up and a gated admin panel came together quickly. The middleware checks no roles, so anything role-specific has to happen in the page itself. - Recoil is in the stack for client state, and `lib/atoms/userDetailsAtom.js` is what it holds. The seat picker never reached it and stayed on component state. ## Running it locally Setup is short, and `pnpm dev` binds port 3001 rather than 3000. ```bash pnpm install pnpm dev curl "http://localhost:3001/api/v1/movies?query=inter&limit=10" ``` That last request is the quickest check that both halves of the catalog path worked. The environment needs `DRIZZLE_DATABASE_URL` for Neon, `TMDB_API_KEY`, `AUTH_SECRET`, the four `TYPESENSE_` variables and `NEXT_PUBLIC_APP_URL`, since the server actions use that one to reach their own routes. Schema commands are `pnpm db:generate`, `pnpm db:migrate` and `pnpm db:push`. ## Where it stopped The payment page renders a checkout screen with a hardcoded film, a hardcoded price and three payment method radios. No gateway is wired behind it, and no code writes a row into `reservation`. Both gaps are listed under known issues in the README. `pnpm db:seed` is stale as well, since it inserts movie columns such as `director` and `trailerUrl` that the schema no longer has. The README advertises trailers, and no page ever rendered one. What does work is the catalog, search, movie detail, cinema list and admin panel. It runs in Docker behind a Caddy container proxying to `app:3001`, hosted on EC2, and is MIT licensed. --- ### QuizItNow - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/quiz-it-now - Summary: Turn any content into quizzes with AI. Generate assessments from text, URLs, PDFs and YouTube videos, with difficulty levels you control. - Published: 2024-08-16T00:00:00.000Z - Topics: Next.js, TypeScript, AI, NVIDIA NIM, MongoDB, shadcn/ui, AWS S3, NextAuth.js QuizItNow turns a piece of content into a quiz. Paste text, drop in a YouTube link, point it at an article, or upload a PDF. It returns questions at the difficulty you asked for. I built it because writing good questions by hand is slow, and most of that slowness is mechanical rather than pedagogical. The whole system is one Next.js application. Extraction, generation and storage all happen inside API routes, so there is no second service to deploy or keep in step. That was not the original shape. ## From Python to TypeScript It started as a FastAPI backend with a separate Next.js frontend. Two languages meant two type systems, two deploy targets and a constant context switch. I rewrote the backend in TypeScript inside the Next.js app, and the routes and the interface began sharing types. In the same pass I moved from LangChain to the Vercel AI SDK, from PostgreSQL to MongoDB, and rebuilt authentication on NextAuth.js v5 with Google and GitHub. The cost is that generation now runs inside a request handler. A slow PDF download and a 70B model call share one function timeout, which a separate worker would not. ## Getting the content in `app/api/quiz/generate/ai/route.ts` decides how to read the input before it prompts anything. A Zod schema validates the body, and a `superRefine` rule rejects a file input sent as a bare string. YouTube links go to `youtube-transcript`. Links ending in `.pdf` are downloaded and handed to `pdf-parse`. Everything else is fetched and run through Cheerio with `script`, `style` and `noscript` stripped out. Uploads reach S3 through a presigned URL from `POST /api/s3` and are read back from there. Whichever path ran, the extracted text has to clear 250 words before a prompt is built. ## Constraining the model Language models are happy to return prose where I need an object. `generateObject` with a Zod schema pins the output to the shape MongoDB expects, at a temperature of 0.3. ```typescript export const generatedQuizSchema = z.object({ title: z.string().min(1), description: z.string().min(1), difficulty: z.enum(['Easy', 'Medium', 'Hard', 'God Mode']), topic: z.string().min(1), tags: z.array(z.string().min(1)).default([]), questions: z.array(generatedQuestionSchema).min(1), }); ``` A valid object is still not a correct one. `sanitizeGeneratedQuiz` collapses true or false options back to `True` and `False`, and pushes the stated answer into the option list when the model leaves it out. A wrong question count is treated as a failed call, not as output. ## Decisions and their costs - Requests walk a list of models instead of one. The chosen model gets two attempts, then Llama 3.1 8B, Llama 3.3 70B, Mistral 7B, Gemma 2 9B, Qwen 2.5 7B and Nemotron Mini take their turn. Something almost always comes back. A user can also quietly get a smaller model, and only the stored `aiModel` field records it. - Anonymous generation works and is never saved. `userId` defaults to `anonymous`, and the database write is skipped for that value. Trying the product costs no signup, and the quiz dies with the tab. - MongoDB replaced PostgreSQL because a quiz is one nested document with a variable question shape. Reading one is a single lookup on `quizId`. Anything analytical across questions becomes application code. - Difficulty changes the prompt, never the sampling. I would revisit this first. The four tiers are prose instructions inside one long template, and I have no measurement that tells them apart. ## Generating a quiz One call covers the whole path from a video to stored questions. ```bash curl -X POST "$APP_URL/api/quiz/generate/ai" \ -H 'Content-Type: application/json' \ -d '{"input":"https://youtu.be/VIDEO_ID","inputType":"link", "numberOfQuestions":10,"difficulty":"God Mode"}' ``` The response carries a `quizId`, the sanitized quiz and the model that produced it. `GET /api/quiz/[quizId]` reads it back and `GET /api/quiz` lists what the signed-in user has saved. Running it locally needs `MONGODB_URI`, `NVIDIA_API_KEY` and `AUTH_SECRET`. The AWS variables only matter for uploads, and `NVIDIA_BASE_URL` already points at the NIM endpoint. ## Where it stops Generation is synchronous, so a slow extraction and a slow model add up to a long spinner and nothing else. `POST /api/audio/transcribe` is a placeholder that transcribes nothing. The prompt templates still instruct the model to refuse anything under 500 words while the route enforces 250, which is a leftover from moving that floor. There is no rate limiting on the generation route. It runs as a public demo, builds into a Docker image serving port 3000 for self-hosting, and is MIT licensed. --- ### Typesense Dashboard - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/typesense-dashboard - Summary: A control plane for Typesense collections, search behavior, migrations and cluster health, in one browser interface instead of curl and Postman. - Published: 2024-08-15T00:00:00.000Z - Topics: Next.js, TypeScript, Typesense, shadcn/ui, Tailwind CSS, React, Radix UI Typesense Dashboard is a browser workspace for running a Typesense cluster. Collections, documents, schemas, synonyms, overrides, stopwords, presets, search testing and cluster health all live in one place. Before it, any of that meant a tab of API documentation, a curl command and a hand-written JSON blob. It is a Next.js application with no database of its own. Every screen reads and writes the Typesense API directly, so the dashboard holds no state the cluster does not already hold. ## One control plane The daily search workflow is not one task. It is a dozen small ones, usually scattered across tools. I wanted them adjacent: build a collection, import documents, tune a synonym, run the query, look at the metrics. Each Typesense resource gets one module under `lib/typesense/`, so `collections.ts`, `documents.ts`, `synonyms.ts`, `search-overrides.ts` and `cluster-operations.ts` each wrap one slice of the API. The pages under `app/(app)/` stay thin over those wrappers. The interface keeps the API visible enough for a developer while giving destructive operations context and safer defaults. ## From cookie to cluster Connection details come from environment variables first, then from an httpOnly cookie named `typesense-connection-config` that `POST /api/save-connection` writes. `middleware.ts` reads whichever it finds and calls the cluster `/health` endpoint behind a two second abort timeout. No configuration sends you to `/setup`. Configuration that will not answer sends you to `/connection-error` instead of into a half-broken screen. A healthy connection redirects `/` to `/metrics`. Server components then build a client per request through `getServerTypesenseClient()`, which reads the same cookie. Browser code shares one module-level singleton, and `resetTypesenseClient()` exists because that singleton has to be dropped when the connection changes. ## Version aware navigation Typesense gains capabilities between server releases, and showing every control to every user would create dead ends. The dashboard reads `/debug`, which returns the running version, and `lib/typesense/version.ts` compares it against a feature map. ```typescript export const FEATURE_MIN_VERSIONS: Record = { stopwords: '26.0', conversationalSearch: '26.0', stemmingDictionaries: '28.0', naturalLanguageSearch: '29.0', synonymsGlobal: '30.0', diversifyResults: '30.0', }; ``` `parseVersion` handles both the old `0.25.2` scheme and the newer `27.1` one, so an old cluster still compares correctly. `TypesenseVersionProvider` puts `hasFeature` into context and the navigation hides what the server cannot do. When detection fails the provider is deliberately permissive and shows everything. ## Trade-offs I accepted - The admin API key lives in a cookie. It is httpOnly, marked secure in production, and expires after fourteen days. A setup wizard becomes possible with no user database behind it. Anyone holding that browser profile also holds admin access to the cluster. - Every wrapper in `lib/typesense/` catches its own errors, logs to the console and returns `null`. Pages stay simple. A caller cannot tell an empty result from a failed call, and that is what I would change first. - Monaco Editor is imported dynamically rather than bundled. JSON is the working surface for documents and schemas, and a textarea is the wrong tool for it. The first visit does not pay for an editor it may never open, at the cost of a visible pause when it does. - The middleware health check runs on every request that is not exempt. Stale configuration surfaces immediately instead of as a failed page. It also adds a round trip to the cluster before anything renders. ## Moving a live schema Changing a live search schema should feel deliberate rather than dangerous, so aliases get a guided flow at `/aliases`. Create `products_v3` with the new schema, load it from `/collections/products_v3/documents/import`, run the query in the search playground, then move the alias. ```typescript await createAlias('products', 'products_v2'); await updateAlias('products', 'products_v3'); ``` The switch is atomic and `products_v2` stays exactly where it was, so a rollback is one more `updateAlias` rather than an incident. The playground holds query, `query_by`, filters, timing, hit counts and per-result scores in one view, then copies the final request out as a curl command. ## Rough edges The version table in the README disagrees with `FEATURE_MIN_VERSIONS` in the code, and the code is what actually gates the navigation. Vitest covers version parsing and the resource wrappers under `__tests__/`, both against a mocked client, so nothing in the suite touches a real server. Cluster operations post straight to `/operations/snapshot`, `/operations/db/compact` and `/operations/vote` through an untyped `apiCall` escape hatch. Nothing authenticates the dashboard itself. It builds without a running cluster, works against Typesense Cloud or a self-hosted node, ships development and production Dockerfiles, and is MIT licensed. --- ### Yoink - Type: Project - Canonical URL: https://www.piyushgambhir.com/projects/yoink - Summary: A media extraction platform with a browser extension, a web dashboard, Lambda streaming downloads, and one backend for auth, billing and analytics. - Published: 2024-02-15T00:00:00.000Z - Topics: System Architecture, Browser Extension, Next.js, NestJS, AWS Lambda, PostgreSQL, Stripe Yoink extracts and downloads video from YouTube, LinkedIn, X, Instagram, Threads, Pinterest and Facebook. It is four services rather than one application. A browser extension handles the in-page action and a Next.js dashboard handles the account. A NestJS backend owns every persistent decision, and Go on Lambda does the extraction itself. The split is not about scale. It is about which parts are allowed to fail. Extraction breaks whenever a platform quietly changes something. Billing must never be wrong. Putting both in one process means the first one takes out the second. ## Why four services Most download tooling keeps extraction, authentication, billing and user state in a single runtime, and that is the thing that breaks. The four surfaces here are a WXT extension, a Next.js dashboard, a NestJS backend and Go handlers behind a Lambda Function URL. Only the Lambda handlers run `yt-dlp` and `ffmpeg`, and they stream the response back instead of assembling a file first. The backend shells out to nothing. A `yt-dlp` change can break extraction now without going anywhere near a subscription record. ## Three trust boundaries There are three authentication modes, each with exactly one job. The extension sends `X-API-Key`, because it runs inside a browser I do not control. The dashboard never holds a backend secret. It proxies through `/api/backend/[...path]`, which injects `Bearer BACKEND_SERVICE_TOKEN` and `X-User-*` context headers, blocks internal paths for non-admins, and streams responses back including download payloads. Lambda presents that same service token against internal endpoints only. It holds no database credentials at all. It authorizes through the backend, logs through the backend, and writes nothing itself. ## A download in flight An extension download begins with the options call. The extension hits Lambda `/options`, Lambda calls backend `/api/internal/authorize`, and the backend checks plan, rate and monthly limits before updating key activity. Usage is logged asynchronously, so the format list is not waiting on a write. The download itself needs a plain URL, because that is what a browser download manager understands. A plain URL would put the API key in a query string. So the extension mints a short-lived signed ticket at `POST /api/auth/download-ticket`, the browser calls Lambda with the ticket, and Lambda exchanges it through the backend before running anything. The backend opens a `download_history` row, Lambda streams the bytes, and completion or failure is posted back afterwards. ![Web Downloader Interface](/images/projects/yoink/images/downloads.png) ## One writer PostgreSQL holds identity and access in `users`, `api_keys` and `device_bindings`. Telemetry lives in `usage_logs`, `download_history` and `extension_installations`. The billing lifecycle sits in `user_subscriptions`, `billing_coupons` and `billing_coupon_redemptions`. The backend is the only writer, including for the events Lambda starts. Consistency stays in one place and the admin analytics queries stay predictable. Extension installs and versions report into the same store, so I can see which client build is actually in the field when a platform changes underneath it. ## Costs of the split - Four services means four deployments and one shared secret. `BACKEND_SERVICE_TOKEN` has to match in three places at once, so rotating it is a coordinated change rather than a config edit. - Routing every Lambda write through the backend removes a class of consistency bug. It also adds a network hop inside each download, and a backend outage stops extraction that would otherwise have worked. - Signed tickets keep the long-lived key out of URLs, and they expire. Ticket expiry is what I would revisit first, because the TTL is tuned for mint-then-download and not for a tab left open over lunch. - Streaming from Lambda gives the native download experience with no intermediate storage. A failure halfway through therefore arrives as a truncated file rather than as a retry. ## Running it Local ports are fixed: extension on 3000, web on 3001, backend on 3002, Lambda on 3003. Two secrets have to line up, `BACKEND_SERVICE_TOKEN` and `DOWNLOAD_TICKET_SECRET`, and Neon gets a pooled URL at runtime with an unpooled one for migrations. The runbook covers stale key deactivation, ticket expiry, monthly limit violations and stuck `download_history` rows. Yoink is in active development. The source is not public, so the hosted product is the only way to use it. ## Technical writing ### Formatting Belongs to Tools, Not Code Reviews - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/code-formatting - Summary: One formatter per language, pinned to a version, enforced at save, commit and CI. The rules I apply across my JavaScript, TypeScript and Python repos, and why I stopped tuning the options. - Published: 2025-05-15T00:00:00.000Z - Updated: 2026-09-26T00:00:00.000Z - Topics: Code Formatting, Tooling, Developer Experience, Prettier, Ruff The worst review comment I have ever left was about a quote character. Single versus double, on one line, in a pull request that changed how a call got routed. I caught myself typing it and deleted it, and then I went and set up the formatter properly, because the comment was not wrong. It was just not my job. Formatting is a class of decision where the outcome matters much less than the fact that a decision exists and gets applied everywhere without a human in the loop. That is the definition of work a tool should own. Below is the setup I actually run, in this site's repo and in every other repo I maintain, and the three rules behind it. ## One formatter per language, and it wins Every repo I own has exactly one formatter per language, its config committed, and nothing that lets an individual file opt out of it. For JavaScript and TypeScript that is [Prettier](https://prettier.io/docs/options). For Python it is `ruff format`. For JSON, YAML, CSS and Markdown, Prettier again, because one tool covering six file types is worth more than the best tool for each. The corollary matters more than the choice: the formatter owns whitespace, and the linter owns correctness. They never overlap. ESLint in my repos runs Next.js core web vitals rules and nothing stylistic. Ruff's lint pass runs pycodestyle, unused imports, naming and import ordering, and none of the rules its formatter documents as conflicting, because two tools with opinions about the same character position is how you get a commit that oscillates forever. ## Pin the version, or the formatter becomes a merge conflict generator `prettier: 3.9.6` in my `devDependencies`, no caret. Ruff capped at a minor version in the dev dependency group, in the repos where I have done it right. This looks like paranoia until the first time a minor release changes how one expression wraps, and a teammate on the newer version reformats four hundred lines while fixing a typo. Formatters are allowed to change their output between releases, and Ruff says so in writing: its [versioning policy](https://docs.astral.sh/ruff/versioning/) treats style changes as minor-version material. Pin it, upgrade it deliberately, and when you do, land the reformat as its own commit that touches nothing else. Add that commit's hash to `.git-blame-ignore-revs` so [blame skips it](https://docs.github.com/en/repositories/working-with-files/using-files/viewing-and-understanding-files#ignore-commits-in-the-blame-view) and you do not lose the history of who wrote a line. ## Three enforcement points, because each one leaks I run the same formatter in three places, and the redundancy is the point. **On save**, in the editor. This is where it costs nothing and where developers stop noticing formatting exists at all. It is also the only layer that is purely voluntary, which is why the other two exist. **On commit**, via a hook. In JavaScript repos that is [Husky](https://typicode.github.io/husky/) plus [lint-staged](https://github.com/lint-staged/lint-staged), so only staged files get touched. For Python, [pre-commit](https://pre-commit.com/) with the official Ruff hooks does the same job, although most of my Python repos skip the hook and let CI be the gate. This catches the contributor who has not configured their editor, and the commit made from a web UI or an agent that bypassed the editor entirely. **In CI**, as a check, never a fix. `prettier --check .` and `ruff format --check .` both exit non-zero on a file that would change. CI does not write commits back to my branches, because a bot that rewrites your work while you are mid-rebase is worse than a red build. If CI is failing on formatting, something above it leaked, and I would rather know. A commit hook can be skipped with `--no-verify`, which is the right escape hatch to leave available and the reason CI gets the last word. ## Stop tuning the options Prettier's defaults are good, and the honest reason to change one is that your team already writes code a certain way, not that the alternative is better. I set `singleQuote: true` and keep `printWidth: 100` in most repos. Both are habits, not arguments. Everything else I leave alone, including `semi: true`, `trailingComma: "all"` and `endOfLine: "lf"`, all three of which are already Prettier's defaults as of version 3. This is the whole reason Prettier's [option philosophy](https://prettier.io/docs/option-philosophy) page exists and why it says no to most new options. Each knob you turn is a decision a new contributor has to discover, and a diff you inherit if you ever drop the override. The correct number of custom options is small, and the correct place to record why each one exists is the repo, not somebody's memory. ## What formatting does not fix A formatted codebase is not a good codebase. The formatter will happily align a function that takes nine positional arguments and a module that imports from three layers above itself. Naming, file layout, module boundaries and API shape are all still review material, and they are the things worth spending review attention on once the whitespace arguments are gone. I wrote separately about [the naming conventions I use in Next.js projects](/blogs/next-js-naming-conventions), which is the layer directly above this one. What formatting buys you is narrow and real: diffs that contain only the change, reviews that are about behavior, and no cost to switching between files written by different people. ## The three guides Each of these is the config I run, option by option, with the reason for every place I deviate from a default. - [Front-end JavaScript and TypeScript](/blogs/code-formatting/front-end-js-ts-code-formatting-standards): Prettier for a Next.js and Tailwind app, import sorting, the two sort-imports plugins and why I moved between them. - [Back-end JavaScript and TypeScript](/blogs/code-formatting/back-end-js-ts-code-formatting-standards): the same config with the front-end plugins removed, and what actually differs on a Node service. - [Python](/blogs/code-formatting/python-code-formatting-standards): `ruff format` plus `ruff check`, and why there is no Black or isort left in any of my repos. --- ### I Stopped Prompting and Started Specifying: How I Work With Coding Agents - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/cursor-100x-practices - Summary: The practices that decide whether a coding agent helps or costs you time: context budgets, spec-first delegation, parallel runs, verification gates, and knowing when to stop prompting. - Published: 2025-02-23T00:00:00.000Z - Updated: 2026-09-26T00:00:00.000Z - Topics: AI, Agents, Developer Tools, Software Development, Productivity When I first wrote this in February 2025, it was a post about Cursor, and it promised 100x productivity. I had no measurement behind that number. I want to be blunt about it, because the post was doing the thing I now find least useful in writing about AI tooling: asserting a multiplier, describing which buttons to press, and calling the result a practice. Almost every tool-specific detail in that version is now wrong. Features were renamed, replaced, or absorbed into something else, and the editor I was writing about is no longer the place most of my work happens. That is the actual argument for writing down habits instead of keyboard shortcuts. So here is the rewrite. These are the practices I use daily to ship Go, Python, and TypeScript with agents doing most of the typing, and the honest accounting of what I can and cannot claim about them. ## The one number I have, and what it does not prove When I [pulled eighteen months of my own commit history](/blogs/issue-tracking-is-dead) across fifteen production repositories, monthly commits went from between 50 and 90 up to roughly 300, and the inflection lines up with rebuilding my workflow around agents. That is throughput. It is not value, it is not quality, and it is not a multiplier. I do not know my defect rate before versus after, and I am suspicious of anyone who says they do. What I can say is that the shape of my day changed: less typing, much more specifying and reviewing. Everything below is about making that trade come out in your favor. ## Context is the input, not the prompt The single biggest lever is what the model can see when it starts. Prompt wording matters far less than people think once the context is right, and no amount of prompt polish rescues a model that is guessing at your conventions. Two things I do: **Keep a repo-level instruction file, and keep it short.** [Claude Code reads `CLAUDE.md`, and will read a repository's `AGENTS.md` too](https://code.claude.com/docs/en/memory). [AGENTS.md](https://agents.md/) is the cross-tool version of the same idea, now stewarded under the Linux Foundation and, by its own count, in over 60,000 open-source repositories. Whichever filename your tools read, treat it as a budget, not a wiki. Build and test commands, the conventions that are not inferable from the code, the directories that are off limits, the two or three mistakes agents repeatedly make in this repo. A stale instruction file is worse than none, because it produces confidently wrong work that is also consistent with your written rules. **Point at files, not at the whole repo.** Whole-codebase retrieval is a convenience for exploration and a liability for editing. When I know the three files that matter, naming them beats letting the agent go looking, every time. The failure mode of too much context is not slowness, it is that the agent anchors on an unrelated pattern it happened to find. ## Spec first, and write the spec for a reader who will not ask questions This is the practice that changed my output most, and it is not an AI practice. It is the same thing I argue about [deciding what to build](/blogs/ai-made-building-faster-we-still-have-to-decide-what-to-build), just with a shorter feedback loop. Before delegating anything non-trivial I write down the goal, the constraints, the files in scope, what is explicitly out of scope, and how I will know it worked. Usually a few hundred words. It takes ten minutes and it routinely saves an hour, because the gap it closes is the one a human colleague would have closed by asking a clarifying question. An agent does not ask. It picks. The corollary is uncomfortable: a vague spec used to produce a stalled ticket, and now it produces a confidently wrong implementation at speed. Ambiguity got more expensive, not less. I have also stopped treating "write the spec" as a chore to hurry through. If I cannot state the acceptance condition in one sentence, I do not understand the task yet, and delegating it is going to cost me more than doing it myself. ## Parallel runs, with disjoint file surfaces Running more than one agent at a time is genuinely useful and routinely oversold. What makes it work is not the number of agents, it is whether their work touches the same files. Two tasks in unrelated directories, or a code change in one session and an investigation in another, parallelize cleanly. Two tasks in the same module produce two plausible diffs that conflict, and reconciling them costs more than doing them in sequence would have. The other limit is me. Reviewing two diffs properly is not faster than reviewing one, and an agent finishing while my attention is elsewhere is how unreviewed code gets merged. I cap myself at what I can actually read. ## Verification gates before the diff counts My rule is simple: nothing lands on the strength of an agent's own report that it works. The gates in order, cheapest first. Type check and lint, because they are free and catch a surprising share of hallucinated APIs. Tests, run by me rather than reported to me. Then actually running the thing, which for voice work means placing a real call and grading it, because a pipeline can pass every test and still leave two seconds of dead air on the phone. Those [graded calls](/blogs/turn-taking-is-the-hardest-problem-in-voice-ai) are the only verification that has ever caught the failures that matter in my domain. The point of automating these is not rigor for its own sake. It is that an agent can run them itself and iterate before handing me anything, which moves the loop off my attention. ## Read the diff, not the summary An agent's summary of its own work is a claim, and the claims are usually accurate about what was attempted and unreliable about what was achieved. Specific things I look for, because they have each bitten me: - **Scope creep.** A refactor nobody asked for, bundled into the fix. It is often an improvement, which is what makes it easy to wave through, and it is also why the diff is now three times bigger than the change it needed to be. - **Tests that assert the implementation.** A test written after the code, by the thing that wrote the code, frequently just restates it. It passes forever and protects nothing. - **Silent fallbacks.** Swallowed errors, default values where an unknown value should have been an explicit failure. In a provider-neutral pipeline this is the worst possible bug, because the system keeps working while doing the wrong thing. - **Deleted safeguards.** A guard clause or a validation that was in the way of making something pass. Volume makes this harder, not easier. Three hundred commits a month is three hundred commits somebody has to have read. If the review is the thing you skip to get the speed, the speed is not real. ## Know when to stop prompting The failure mode I still fall into is correcting instead of restarting. The agent gets it 80 percent right, I explain what is wrong, it fixes that and breaks something else, and I explain again. Two rounds of that and I am negotiating with a transcript instead of solving a problem. My rule now is two corrections. After that I either rewrite the spec and start a clean session, or I write the code myself. Almost always the original spec was the problem, and continuing the conversation just accumulates context that argues for the wrong approach. There is also a category of work I have stopped delegating at all. Anything where the hard part is a judgment call I cannot articulate yet, and anything touching the parts of the system where I need the mental model in my own head, because losing that is a cost that shows up weeks later during an incident. ## What I am still unsure about Whether any of this nets out positive in value delivered rather than commits produced. Throughput I can count. Quality I mostly cannot, and the honest position is that I have a strong subjective sense and no instrumentation. I also do not know how much of my current practice is durable and how much is compensating for this year's limitations. The verification discipline I expect to outlive every tool in my setup. The context choreography is probably temporary scaffolding that better models make unnecessary, and I would not be sad to lose it. --- ### Your Docs Have a Second Reader Now, and It Does Not Scroll - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/docs-are-the-new-ai-knowledge-base - Summary: Practical ways to make documentation readable by agents: llms.txt, plain-markdown endpoints, stable URLs, answer-first pages, and doc servers. With what I could verify and what I could not. - Published: 2025-06-16T00:00:00.000Z - Updated: 2026-09-26T00:00:00.000Z - Topics: Documentation, LLMs, Agents, Developer Tools, MCP Most of the documentation I depend on, I no longer read first. An agent reads it. When I am debugging LiveKit worker behavior or a Twilio SIP setting, the sequence is that a coding agent fetches the relevant page, quotes the part that matters, and I go verify the part I do not believe. I still read docs. I just read them second, after something else has narrowed them down. That changes what makes a documentation site good, and not in the direction most doc advice points. An agent does not scroll, does not recognize your navigation, does not learn your information architecture across visits, and does not get a tooltip. It gets a chunk of text and has to answer from it. I first wrote this post in June 2025, and it was mostly assertion. It leaned on a couple of dramatic search-traffic statistics I could not properly source, and it quoted Andrej Karpathy from memory without a link to the actual post. I have cut all of that. What follows is what I could check myself, with the commands I checked it with, plus what I still cannot measure. ## The thing you can do today that measurably helps Serve a plain-markdown version of every page at a predictable URL. Several documentation sites now do this by appending `.md` to the page URL. Checked on September 26, 2026: ```bash curl -sL https://code.claude.com/docs/en/memory.md curl -sL https://docs.livekit.io/agents.md curl -sL https://docs.stripe.com/payments.md curl -sL https://developers.cloudflare.com/agents/index.md ``` All four return `text/markdown`. The size difference is the point: | Page | HTML | Markdown | | ------------------------------ | ------- | -------- | | code.claude.com/docs/en/memory | ~759 KB | ~54 KB | | docs.livekit.io/agents | ~379 KB | ~10 KB | Be careful reading those numbers. The HTML figure includes the application shell, inlined scripts, and framework payload, not just prose, so the ratio overstates how much _text_ is saved. But the direction is real and the mechanism is obvious. Every byte of navigation markup, cookie banner, sidebar, and client-side hydration data that reaches the model is context spent on something that is not your answer, and worse, it is context the model might misread as content. This is the change with the clearest cost-benefit case. It is cheap to add, it needs no cooperation from any AI vendor, and it works with a plain HTTP fetch, which is the one capability every agent has. ## llms.txt, and what it is honestly worth [Jeremy Howard proposed `llms.txt`](https://www.answer.ai/posts/2024-09-03-llmstxt.html) in September 2024, with the specification at [llmstxt.org](https://llmstxt.org/). The shape is a markdown file at your root: a title, a one-line summary, then curated sections of links with a sentence of description each. The idea is a map for a reader with a limited context window, not a full dump. Adoption among developer-facing docs is real. Live as of today: ```bash curl -sL https://docs.anthropic.com/llms.txt curl -sL https://docs.livekit.io/llms.txt curl -sL https://developers.cloudflare.com/llms.txt curl -sL https://docs.stripe.com/llms.txt curl -sL https://mintlify.com/docs/llms.txt ``` Now the honest part. `llms.txt` is a convention, not a standard, and unlike `robots.txt` it has no enforcement mechanism and no published commitment from any major crawler that it is consulted. So the defensible reason to ship one is not that it will be crawled, it is that it is the file you point an agent at, by URL, when you want it grounded in your docs. There is at least one clear signal that vendors are designing for exactly that. Fetch `code.claude.com/docs/en/memory.md` today and the first three lines of the response are a pointer back to `code.claude.com/docs/llms.txt`, telling whatever is reading to fetch the index before exploring further. The doc is written on the assumption that its reader is an agent that arrived mid-site and needs a map. This site ships two files. [`/llms.txt`](/llms.txt) is the index, and [`/llms-full.txt`](/llms-full.txt) is the long-form text of every public article and project in one document. Both are generated from the same frontmatter and MDX that render the pages, which is the part I would insist on: a hand-maintained machine file drifts from the human one within a month, and then you are shipping two versions of the truth. Anything I mark unlisted is excluded from both, so the machine view and the public view agree about what exists. ## Structure for a reader that gets one chunk Retrieval does not hand a model your page. It hands it a fragment, usually without knowing what came before it. Everything below follows from that. **One page, one question, one stable URL.** The unit of retrieval is roughly a page or a section, so a page that answers three questions gets retrieved for all three and answers each one partially. Split them. **Put the answer in the first paragraph.** Not the motivation, not the history of the feature, not what the reader will learn. The chunk that gets retrieved is usually the top of the page, and an agent will confidently answer from it whether or not the real answer was further down. **Make headings self-contained.** "Configuration" tells a model nothing once it is detached from the page title. "Configuring SIP trunk failover" survives on its own. **Write examples that run.** Complete imports, real parameter names, and a pinned version. Fragments with `...` in the middle get reassembled by the model into something plausible and wrong, and the user blames your API. This is the single most common way I watch agents produce broken code against a real library. **Document what the thing does not do, and the exact error strings.** Negative information is what stops an agent from inventing the method you never shipped. If a limit exists, state the number. If a call fails in a specific way, put the literal error text on the page, because that string is what somebody is pasting into a prompt. **Never break a URL.** This mattered before, and it matters more now, because URLs are what end up quoted in a model's answer and cached in someone's agent config. An anchor is an address. Redirect everything, forever. ## Doc servers, which cut out the guessing The next step up from serving good text is serving it over a protocol the agent can query. The [Model Context Protocol](https://modelcontextprotocol.io/) is the emerging shape for that, and a documentation server is one of its most obviously correct applications. Instead of an agent fetching a page and hoping it is the right one, it searches your docs and gets back the relevant sections. I would not oversell this relative to the markdown endpoints. A doc server requires the user to have configured it; a `.md` URL requires nothing. Do the cheap one first. But if you maintain a large API surface, being queryable rather than crawlable is the difference between an agent finding your current endpoint and repeating one it memorized from a 2024 blog post. ## What I cannot measure, and it bothers me I do not know whether any of this improves the answers people get about my work. I cannot tell from my logs whether an agent that answered a question about voice agents read my `llms-full.txt`, read a page, or answered from training data that happens to include me. There is no attribution channel. Requests from AI clients arrive looking like requests, and the answer is assembled somewhere I cannot see. That is a genuinely unsatisfying place to be, and it is why I am skeptical of confident claims in this area, including the ones I made in the first version of this post. Nobody optimizing docs for LLMs right now has a feedback loop worth the name. So I have settled on doing the things that are defensible without measurement. Clean markdown at a predictable URL costs almost nothing and is strictly better than shipping an application shell to a text model. Answer-first pages and runnable examples are things I wanted anyway, for humans, and the machine case only makes them more urgent. Stable URLs were always the right call. An `llms.txt` is cheap and I get direct use out of it myself. The advice I would resist is the reframing of documentation as a content-marketing channel aimed at models. The reason to write a precise page that states its limits and shows a complete example has not changed. The audience just got larger, less forgiving, and considerably worse at guessing what you meant. --- ### Everything I Ship Is a Wrapper. The Model Was Never the Product. - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/emergence-of-ai-wrappers - Summary: What a product built on someone else's model actually has to own: the workflow, the integrations, the evaluation harness, and the failures the provider will never see. - Published: 2025-05-17T00:00:00.000Z - Updated: 2026-09-26T00:00:00.000Z - Topics: AI, Voice AI, Product, Architecture, LLMs The voice agents I run at Spyne are a wrapper. A language model I did not train, a speech-to-text model I did not train, a text-to-speech model I did not train, and a real-time framework I did not write. Strip those four out and there is no product. I want to start there, because "it's just a wrapper" is usually said as a dismissal, and I have spent two years finding out how much work is hiding inside the word "just." When I first wrote this in May 2025, I made the argument the way everyone was making it at the time: by quoting valuations. A reported three billion dollar offer here, a nine billion dollar valuation there, as though the price tag proved the thesis. Since then, the specific deals I cited resolved in ways the headlines did not predict, and at least one of the companies I held up as the model got taken apart and sold in pieces. That was a useful lesson about citing valuations as evidence. So the numbers are gone from this post, and what is left is the question they were standing in for. **If the model is not yours, what do you actually own?** ## The model is a commodity input, and you should design like it is The uncomfortable version first. Every capability I depend on gets better, cheaper, and more widely available on a schedule I do not control. That cuts both ways. A better model arrives and my product improves for free. It also arrives in my competitor's product on the same day. So anything that improves when the provider ships is not a moat. It is a floor that keeps rising under everyone at once. Prompt engineering is not a moat. A nicer chat interface over the same model is not a moat. Being three months earlier than someone else is a head start, not a position. The parts that do not move when the provider ships are the interesting ones. In my case, they turned out to be almost everything except the model. ## What actually has to be yours ### The workflow, not the conversation A voice agent that can hold a conversation is a demo. The product is what surrounds the conversation: which lead gets called, when, how many attempts, what counts as reached, what gets written back to the CRM, what happens on a callback, when a human takes over. None of that is a model capability. It is the dealership's actual follow-up process, encoded. The platform I built expresses it as a typed runtime profile per call, naming providers, prompts, tools, transport, recording, voicemail behavior, idle handling, duration limits, and transfers. That profile is the product. The model is one field in it. ### The integrations nobody wants to build The model pipeline was not the hard part. The phone system was. SIP trunking. Three carriers behind [one internal telephony API](/blogs/one-api-many-carriers-provider-neutral-telephony). [Caller identity, CNAM, and STIR/SHAKEN attestation](/blogs/stir-shaken-cnam-and-why-nobody-answers-your-ai-calls), because an unattested number gets labeled as spam and never answered. A2P 10DLC registration for the messaging alongside it. Banned-number filtering. This work is boring, it is slow, and it is mostly regulatory. That is exactly why it is defensible. An excellent model does not shorten carrier onboarding, and nobody gets to skip it. ### The failures the provider cannot see Model providers see requests. They do not see the caller who said hello twice and hung up because the agent went quiet for two seconds. They do not see [answering machine detection returning a verdict after the agent already started speaking](/blogs/answering-machine-detection-is-turn-takings-evil-twin). They do not see what narrowband phone audio does to a model that was benchmarked on clean recordings. Those failures are my problem, and the record of them is mine. Per-stage latency, end reasons, transcripts keyed by call and tenant and use case, false-human and false-machine rates tracked separately. That accumulated knowledge of how the system fails in the field is the asset, more than any dataset I could hand to a fine-tuning job. ### The ability to grade a change This is the one I underrated at the start, and the one I would now put first. If you cannot measure whether a swap made things better, you cannot swap. Which means you are structurally locked to whichever provider you happened to start with, no matter how portable your code is. Evaluation is not a quality practice, it is the thing that keeps your options open. For [cost work](/blogs/the-real-cost-of-a-voice-agent-minute) this is the whole game. Cheaper per minute is trivially achievable and frequently worse. Task completion, transfer success, premature interruptions, and p95 response gap have to sit in the same report as the bill, or a shorter and less useful conversation reads as a win. ### Reliability, which is unglamorous and non-negotiable A chat product that fails renders an error state. A phone call that fails has a person on the other end of it, in real time, who now thinks your customer's business is broken. Disaster recovery, failure handling, and bounded behavior under load are not features anyone asks for in a demo. They are the entire difference between something a dealership will point at its inbound line and something it will not. ## Provider-neutral is a structural bet, not a preference The design decision I am most glad about is treating the runtime as replaceable before I needed to replace it. I wrote a typed call contract while still on a managed platform, which bought nothing visible for months. Then [the migration to LiveKit](/blogs/migrating-production-voice-agents-from-vapi-to-livekit) added a second worker behind the same contract instead of starting a rewrite, and traffic moved by profile rather than by flag day. A second worker on a different framework still runs against the same profiles, which is how a new runtime gets graded before any real call reaches it. The cost is real and I will name it. Every contract change now has to cross two workers, and the abstraction is wrong in places where the two frameworks genuinely disagree. I pay that tax deliberately, because the alternative is that my product's shape is decided by whichever vendor's SDK I happened to import. ## The part of the old argument I got wrong The original version of this post had a section on token economics: providers profit from more tokens, wrappers profit from fewer, therefore the arrangement is unsustainable and consolidation follows. I now think that framing was too neat. Prices per token kept falling, and the number of tokens a useful task consumes went up at least as fast, because reasoning, long context, and tool loops all spend tokens. More to the point, in my own cost breakdown the language model is not the largest line. Telephony, speech, and runtime capacity all compete with it. An analysis built entirely on token prices would have pointed me at the wrong optimization for over a year. The thing that is genuinely unsustainable is narrower: a product whose only function is to pass text to a provider and format the reply. That gets absorbed, and it should. The providers are steadily moving up the stack, shipping agent frameworks, tool execution, and increasingly the orchestration layer too. I fully expect some of what I maintain today to become a checkbox in somebody's platform. ## What I do not know Whether owning the pipeline is the right call at low volume. It probably is not. A managed platform arrives with media transport, telephony, and interruption handling on day one, and at a small number of concurrent calls that fee is cheaper than the engineering to replace it. I owned my pipeline because of reach into the failure modes, not because of the unit price, and I would be suspicious of anyone who tells you the arithmetic works at every scale. I also do not know where the line lands. If a provider ships credible turn-taking, carrier-grade telephony, and per-stage observability in one product, a large part of my answer to "what do you own" gets thinner. My bet is that the dealership-specific workflow, the integration surface, and the accumulated record of how real calls fail outlast the parts that are currently mine by default. That bet is the actual content of the word "wrapper." Not whether you built the model. Whether anything would be left if the model were handed to everyone tomorrow. --- ### The Next.js Naming Conventions I Actually Follow - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/next-js-naming-conventions - Summary: I audited twelve of my own Next.js repos before writing this down. Here are the naming rules that survived the audit, the two places my repos disagree with each other, and the rules the framework decides for you. - Published: 2025-05-16T00:00:00.000Z - Updated: 2026-09-26T00:00:00.000Z - Topics: Next.js, Naming Conventions, App Router, TypeScript Most naming convention posts are wish lists. This one started as a wish list too, so before republishing it I went and counted: twelve of my own Next.js repos, every `.ts` and `.tsx` file in each, every route folder. Two useful things fell out. First, about half of what I had written down was true. Second, the half that was false was false in a specific way: I had invented rules for things the framework already decides, and left the genuinely contested decisions vague. So this is split accordingly. Rules the framework owns, where your only job is to spell them correctly. Rules I own, where I will tell you what I picked, why, and where my own repos still disagree with each other. ## Summary | Thing | Convention | Example | | ------------------------------ | ---------------------------------------------------------- | --------------------------------------- | | Files and folders I name | kebab-case | `components/blogs/blog-card.tsx` | | Special App Router files | exact framework names | `page.tsx`, `layout.tsx`, `route.ts` | | Route segment folders | kebab-case, lowercase | `app/glossary/`, `about-us/` | | Route groups | `(name)`, lowercase, no URL effect | `app/(app)/`, `app/(website)/` | | Non-routable colocated folders | `_name` | `app/demos/_playgrounds/` | | Dynamic segments | short lowercase noun in brackets | `[slug]`, `[...slug]`, `[[...slug]]` | | Components | PascalCase export from a kebab-case file | `page-header.tsx` exports `PageHeader` | | Hooks | `use` prefix in both the name and the file | `hooks/use-outside-click.ts` | | Types and interfaces | PascalCase, no `I` prefix | `interface Frontmatter { ... }` | | Functions and variables | camelCase | `normalizeSiteUrl`, `configuredSiteUrl` | | Module constants | UPPER_SNAKE_CASE | `PROJECT_CHAPTERS` | | Env vars | UPPER*SNAKE_CASE, `NEXT_PUBLIC*` when the browser needs it | `NEXT_PUBLIC_APP_URL` | | Client storage keys | short app prefix, kebab-case | `pg-sound`, `pg-loaded` | | Cookies | lowercase, plus a `__Host-` prefix when you can | `__Host-session` | | Query params | lowercase, single word where possible | `?url=`, `?v=` | | Tests | mirror the source filename | `use-capabilities.test.tsx` | ## The framework owns the `app` directory Inside `app/`, filenames are an API. You are not naming these files, you are matching a string Next.js looks for. The current set is `layout`, `page`, `loading`, `not-found`, `error`, `global-error`, `route`, `template`, `default`, plus `forbidden` and `unauthorized`. The full list lives in the [project structure docs](https://nextjs.org/docs/app/getting-started/project-structure), and it grows, which is the single best argument for keeping your own files out of the way of it. Folder conventions are the same kind of contract: - `(group)` is a [route group](https://nextjs.org/docs/app/api-reference/file-conventions/route-groups). The parentheses are stripped from the URL, so it exists purely to share a layout or split one. - `_folder` is a private folder. It and everything under it opts out of routing. - `[slug]`, `[...slug]`, `[[...slug]]` are a [single dynamic segment, a catch-all, and an optional catch-all](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes). - `@slot` names a [parallel route](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes) slot, and `(.)`, `(..)`, `(...)` prefixes are [intercepting routes](https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes). I have not shipped a parallel or intercepting route in any of these twelve repos, so I have no earned opinion there beyond spelling. I have shipped route groups: `nexora-marketplace` splits `app/(app)/` from `app/(website)/` so the logged-in shell and the marketing shell get different root layouts at the same URL depth. That is the one thing route groups do that nothing else does, and it is the only reason I reach for them. **The common mistake:** using a route group where a private folder belongs. If the folder holds components rather than routes, `_components` is the honest spelling, and it removes any chance of a future Next.js convention colliding with it. `web-dev-assets` has `app/demos/_demos/`, `_playgrounds/` and `_components/` for exactly this. Worth noting that colocation already works without the underscore, since a folder is only routable once it contains a `page` or `route` file. The underscore is a guarantee, not a requirement. ## Use `proxy.ts`, not `middleware.ts` As of Next.js 16 the `middleware` file convention is deprecated and renamed to `proxy`. The file is `proxy.ts` at the project root, or inside `src/` if you use one, at the same level as `app/`. It must export a single function, either as the default export or named `proxy`. There is a codemod, `npx @next/codemod@canary middleware-to-proxy .`, which renames both the file and the function. The [rename is documented](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) as a deliberate signal: the old name invited people to treat it as Express middleware and reach for it first. Two of my repos have a `proxy.ts` and both use it for exactly one thing, vanity redirects (`/github`, `/linkedin`). That is about the right amount of ambition for this file. **The common mistake:** copying a `middleware.ts` snippet from a 2024 blog post into a Next 16 app and wondering why nothing runs. ## Metadata files are named, not configured `icon.tsx`, `apple-icon.tsx`, `opengraph-image.tsx`, `twitter-image.tsx`, `sitemap.ts` and `robots.ts` are all [file-based metadata conventions](https://nextjs.org/docs/app/api-reference/file-conventions/metadata). Put the file in the right segment and the tag or the route appears. This repo has all six in `app/`. **The common mistake:** hand-rolling a `/sitemap.xml` route handler when `app/sitemap.ts` returning an array would do. ## Files I name are kebab-case Every file and folder whose name is mine, not the framework's, is lowercase with hyphens. `components/blogs/blog-card.tsx`, `lib/utils/format-date.ts`, `hooks/use-outside-click.ts`. The reason is not aesthetics, it is case sensitivity. macOS is case-insensitive and Linux is not, so `import Card from './Card'` against a file committed as `card.tsx` works on my laptop and fails in CI. Git also tracks case changes badly enough that renaming `Header.tsx` to `header.tsx` needs two commits or a `--force` flag. Lowercase everywhere means the class of bug does not exist. The secondary reason applies only inside `app/`: the folder name becomes the URL segment, and [Google's guidance on URL structure](https://developers.google.com/search/docs/crawling-indexing/url-structure) prefers hyphens to separate words. **Here is where my repos disagree with each other.** Seven of the twelve use kebab-case component files (roughly 217 files: `piyushgambhir.com`, `prism`, `web-dev-assets`, `nexora-marketplace`, `image-everything`, `pdf-everything`, `madhavgambhir.com`). Four use PascalCase (roughly 186 files: `codiot-web` and three client marketing sites). That is close enough that I cannot pretend the kebab side won on merit alone, so here is the honest tiebreak: the PascalCase repos are marketing sites and site ports, built against an existing design, and the naming came from whatever the first component was called. The kebab repos are the ones I have maintained for more than a month. Everything I have touched most recently, including this site, is kebab-case, and when I go back to `codiot-web` I will rename rather than keep two systems. **The common mistake:** letting the exception be a directory rather than a file. One PascalCase file in a kebab folder is a typo you fix. A whole `components/` tree in the other style is a decision, and then every new file needs a judgment call. ## Components are PascalCase in code, kebab-case on disk `components/ui/page-header.tsx` exports `PageHeader`. The two cases are answering different questions: PascalCase in code is a hard React requirement, because JSX resolves a lowercase tag to a DOM element, so `` silently becomes an unknown HTML tag. Kebab-case on disk is the filesystem rule above. The connection I do enforce is that the file name is the component name, lowercased and hyphenated. Given `` in JSX, I should be able to guess `stats-widget.tsx` and be right. That predictability is the entire payoff of a naming convention, and it is worth more than having the two cases match. **The common mistake:** a file whose name has drifted from its export, usually because a component got renamed and the file did not. ## Hooks carry `use` in both places `hooks/use-outside-click.ts` exports `useOutsideClick`. The prefix on the function is not style, it is how React's linter decides whether to apply the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) to a function at all, and [custom hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) are defined by the prefix rather than by anything the type system can see. Keeping `use-` in the filename is my choice, and it earns its keep in fuzzy file search: typing `use-` lists every hook in the project. My shared hooks directory is carried between repos almost verbatim (`use-debounce.ts`, `use-local-storage.ts`, `use-media-query.ts`, `use-event-listener.ts`), and in three repos it has an `index.ts` barrel. That barrel is the one place I still use one. Barrel files inside `app/` defeat tree shaking and route-level code splitting, and a barrel over a `components/` tree turns one import into a whole-directory dependency. A small barrel over a leaf directory of independent hooks is fine; `components/index.ts` re-exporting forty components is not. **The common mistake:** naming a non-hook `useSomething`, which makes the linter enforce hook rules on a plain function. The reverse is worse: a hook named `authState()` that calls `useState` internally and gets no linting at all. ## Dynamic segments are short lowercase nouns Across all twelve repos, every dynamic segment I have written is a single lowercase word: `[slug]`, `[id]`, `[city]`, `[industry]`, `[tool]`, plus `[...slug]` and `[[...slug]]`. I used to advise `[userId]` over `[id]`, and the audit says I never once did it. The reason is that the segment name is only ever read next to its parent: `app/locations/[city]/[industry]/page.tsx` needs no further disambiguation, and `params.city` reads better than `params.cityId`. The framework does not care about the case, so if I ever need two words I would write `[projectid]`, not `[projectId]`, to keep the URL predictable if the value ever leaks into a path. **The common mistake:** writing Express route syntax in a Next.js context. `/:projectId` is not a thing here; the folder is `[projectid]` and the URL is `/123`. ## `lib/`, `utils/`, `hooks/`, `providers/` The split I use is by dependency direction, not by file type. - `lib/` holds modules that know about my domain or about a third party: `lib/seo.ts`, `lib/github.ts`, `lib/analytics/amplitude.ts`. - `lib/utils/` (or a top-level `utils/` in older repos) holds pure functions with no app knowledge: `cn.ts`, `format-date.ts`, `slugify.ts`. - `hooks/` holds reusable hooks. A hook used by exactly one component lives next to that component. - `providers/` holds context providers, one per file, named for what they provide: `providers/gsap-provider.tsx` exports `GsapProvider`. `src/` is the one thing I am genuinely split on. Five repos use `src/app/`, seven put `app/` at the root, and I do not think the choice matters as long as `proxy.ts` sits beside `app/` either way. **The common mistake:** letting `utils/` become the folder for anything you could not name. If a file in `utils/` imports from `lib/`, it is not a util. ## Types are PascalCase, and they live next to what they describe `interface Frontmatter`, `type ContentItem`. No `I` prefix, no `Type` suffix. TypeScript already distinguishes types from values, so the prefix carries no information. Where they live is the part I had wrong. I used to say "a `types/` directory." What I actually do is put shared content types in `types/content.ts`, ambient declarations in `types/global.d.ts`, and everything else in a `types.ts` next to the module that owns it, like `lib/tools/types.ts`. Props types stay in the component file, named after the component with a `Props` suffix: `PageHeaderProps`. **The common mistake:** a central `types.ts` that every module imports from, which becomes an import cycle magnet and a file nobody can safely delete from. ## Env vars, and what a `.env` line actually looks like UPPER*SNAKE_CASE, and the `NEXT_PUBLIC*`prefix only when a value has to reach the browser. Anything without the prefix is server-only, which is the actual security boundary, not a style choice. My real names look like`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_CLARITY_PROJECT_ID`, `RESEND_API_KEY`, `DATABASE_URL`. A `.env` file is not JavaScript. No quotes unless the value contains spaces, no semicolons, no spaces around the equals sign: ```bash NEXT_PUBLIC_APP_URL=https://www.piyushgambhir.com RESEND_API_KEY=re_xxxxxxxx ``` `NEXT_PUBLIC_` values are inlined into the client bundle at build time, which has a consequence people get caught by: changing one requires a rebuild, not a restart, and the value is public forever once shipped. **The common mistake:** prefixing something with `NEXT_PUBLIC_` to make an error go away, and shipping a secret. If a server component needs it, it does not need the prefix. ## Client storage keys and cookies Here is the other place the old version of this post was wrong. I claimed camelCase for `localStorage` keys. What I actually ship is a short app prefix and kebab-case: `pg-sound`, `pg-loaded`. The prefix is the load-bearing part. Storage is per origin, so anything else served from that origin, including a stray script, shares the namespace. A two-letter prefix costs nothing and makes a key greppable. The case is arbitrary, but kebab matches the rest of the project, and a storage key is a string, not an identifier. Cookies are not camelCase either. I keep them lowercase and reach for the `__Host-` or `__Secure-` [cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) when the cookie matters, because those prefixes are enforced by the browser rather than being decoration. A correct `Set-Cookie` header for a session looks like this, and note that `__Host-` requires `Secure`, `Path=/`, and no `Domain` attribute: ```http Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax ``` **The common mistake:** an unprefixed `session` cookie with no `HttpOnly`, set from client JavaScript because that was the shortest path. ## Query params The old version of this post claimed snake_case for query parameters and cited "several blogs." I went looking for the snake_case params in my own code and there are none. What I have is `?url=` and `?v=`: lowercase single words. So the rule is just that, lowercase and short, and the reason is that most query params are not yours to name. They arrive from an analytics tool, an OAuth provider, or a payment redirect, and consistency with your codebase is not on offer. Reserve your opinion for the ones you invent. ## Tests mirror the file under test `__tests__/hooks/use-capabilities.test.tsx` tests `hooks/use-capabilities.ts`. The test filename is the source filename plus `.test`, which keeps the two sorted together and makes the missing test obvious. In `image-everything` the tests live in a `__tests__/` tree that mirrors the source layout; colocating them next to the source works equally well as long as you pick one. ## What I would actually enforce Not all of this is worth a lint rule. Three things are, because they cause bugs rather than arguments: filename casing (a case-sensitivity failure in CI), the hook prefix (it gates the React linter), and `NEXT_PUBLIC_` discipline (it leaks secrets). The rest is worth being consistent about and not worth relitigating, which is the whole reason to write it down once. The test for any of it is the same. Given a name in one place, can you guess the name in the other place and be right? If yes, the convention is doing its job. If you have to go look, it is decoration. --- ### AI Made Building Faster. We Still Have to Decide What to Build. - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/ai-made-building-faster-we-still-have-to-decide-what-to-build - Summary: Small teams can ship more with AI. But unclear requirements still send us back to rebuild. My thoughts on planning, prototypes, and engineering judgment. - Published: 2026-09-07T00:00:00.000Z - Topics: AI, Software Development, Product Management, Engineering I love how much a small team can build now. An idea that would have sat in a backlog can become something you can actually use. You can try things you would previously have talked yourself out of. The cost of getting started has dropped, and I think that is a huge opportunity. But I keep coming back to something that feels almost boring to say. **We still struggle to decide what we are building.** AI has made development faster. It has not made everyone in the room mean the same thing when they describe a feature. And from what I have felt building with it, a lot of the time still goes into rebuilding. You get something working, realize it was built around an assumption nobody discussed, and start pulling it apart. That problem existed long before AI. We can just get further into the implementation before dealing with it now. There is a version of the AI conversation that goes: building is easy now, so just make it and figure it out. I understand the appeal. Sometimes that is exactly the right thing to do. If you need to see whether an interaction feels good, make a rough version. If you have a question that a prototype can answer, go answer it. Where I get uncomfortable is when we start treating a working prototype as evidence that we understand the product. You can have something running and still be very unclear about what it needs to become. Consider something as ordinary as “let users share a project.” Sounds small. You can probably get a share button working quite quickly. But what does sharing mean? A public link? An invitation? Can the other person edit? Do they see future changes? Can access be taken away? What happens to their contributions when it is? Those answers affect how you build the thing. If everyone has a different answer in their head, the first implementation quietly picks one for you. Then someone sees it and says, “Oh, I assumed it would work differently.” Nobody necessarily did a bad job writing the code. The code may do exactly what it was asked to do. The team just never finished the conversation. I think this is easy to miss when you are moving fast. Every individual addition looks manageable. Add this screen. Change that flow. Support one more case. After a point, you have a collection of decisions that made sense individually but do not fit together very well. Extending the project becomes harder than it should be. You start thinking about a rewrite. The speed was real. So was the rework. This is why I feel spending more time on planning matters more now. Especially in a small team, where a few people can turn an unclear idea into a surprisingly large amount of software. I want to be able to describe the core experience before building too much of it. Who is using this? What are they trying to get done? What belongs in this version? What are we deliberately leaving for later? Which assumptions would be painful to reverse? Write those answers down. Sketch the flow. Walk through an actual example together. A spec earns its place when it catches a disagreement while changing our minds is still cheap. The length of the document is beside the point. Two pages that settle the important questions can be more useful than twenty pages that repeat the idea in different words. There is a limit to this, obviously. You cannot plan your way into knowing everything. Sometimes you rebuild because a user shows you that your original idea was wrong. That is useful learning. I would happily take faster development if it helps us get to that realization sooner. But rebuilding because we learned something from users feels very different from rebuilding because we never agreed on what “sharing” meant. I want to get better at telling those apart. AI can help with the planning too. It can question a spec, suggest cases we have missed, or produce a few versions of a flow for us to compare. I find that a much more interesting use of it than rushing straight to implementation every time. Still, someone has to decide which of those possibilities matters. A detailed answer is not automatically an agreed requirement. This also changes how I think about product and engineering working together. I get frustrated when the ease of creating a demo becomes “engineering should be easy now.” At the prototype level, yes, building has become dramatically easier. I am excited about that. Once people depend on the product, there is more to answer for. Their data needs to survive changes. Permissions need to behave consistently. Failures need a way back. The next feature needs to fit without breaking what they already use. There is still a lot of engineering in that. And good engineers have something valuable to contribute before any of it is written. They understand what is possible, where the difficult choices are, and when there is a simpler way to solve the same problem. An engineer might hear a requirement and say, “We can get the user what they need without building that entire workflow.” Or, “If we expect teams to use this later, we should make this decision differently now.” That can change the product itself. You lose those options when engineering only enters the conversation after the solution has been decided. I do not think this means engineers automatically know what users want. Knowing what can be built is only part of deciding what should be built. Customer understanding and business judgment matter just as much. But I do think defining the product has to be shared work. A PM cannot hand over all the uncertainty in a ticket and expect implementation speed to resolve it. An engineer cannot ignore the purpose of a feature and expect a technically sound result to make it useful. The opportunity for small teams is that this conversation can happen directly. The people understanding the problem and the people building the solution can work through it together, then use AI to move quickly on what they have learned. I am more optimistic about that than about the idea that we can skip the thinking now. We have more room to experiment. More chances to build something good with fewer people. More ways to test an idea before committing to it. I just want more of that speed to survive past the first demo. For me, that means getting clearer about what we are building, early enough that we do not have to discover it by rewriting everything. --- ### One API, Many Carriers: Designing a Provider-Neutral Telephony Layer - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/one-api-many-carriers-provider-neutral-telephony - Summary: Product code should talk to a capability, not a vendor SDK. Lessons from running Twilio, Vonage and Telnyx behind one interface. - Published: 2026-09-05T00:00:00.000Z - Topics: Telephony, Architecture, Twilio, NestJS, Voice AI Every telephony integration I have seen starts the same way. Import the Twilio SDK, ship the feature, move on. Then a second use case needs a provider with better rates in one region. Or an outage takes out every call path you have at once. Or procurement wants leverage in a renewal. And you find out the vendor SDK has spread through your product code like ivy. At Spyne I built and ran the telephony layer for our voice agents across Twilio, Vonage and Telnyx, and later distilled the pattern into a standalone NestJS service of my own. This is the design, and more usefully, the places where it earns its keep and the places where it does not. ## Capabilities, not vendors The whole thing rests on one rule: product code talks to a capability, never to a provider. SMS, voice, verification, number lookup, number provisioning, messaging services. Each one is a provider-neutral interface with its own controller. Nothing in product code imports a vendor SDK. It asks the SMS capability to send a message and does not know who delivers it. Providers become adapters that implement some subset of those interfaces, and a registry resolves which adapter serves which capability at request time, per capability. That last part matters more than it looks. In my own service the choice comes from environment configuration; resolving it per request does not make it an automatic health-based failover system. Real deployments are mixed. You might route voice through whichever carrier sounds best into your market, SMS through whoever holds your A2P registration, and verification through a third provider entirely, all from one deployment, chosen by configuration. If this sounds like what I wrote about [swapping voice-agent engines under a stable control plane](/blogs/migrating-production-voice-agents-from-vapi-to-livekit), it should. Same idea. Define your own contract, make vendors satisfy it, and vendors become replaceable. Telephony is just where I learned it first. ## The trap The classic way provider abstraction goes wrong is designing the interface around what every provider has in common. You end up unable to use the reasons you picked a provider in the first place. Twilio’s A2P 10DLC registration, TrustHub, TaskRouter and subaccounts have provider-specific lifecycles. A deployment that uses them needs to preserve those details rather than force them into a generic send method. So the layer is unapologetically two-tier. The capability layer covers the traffic that is genuinely portable, which is most of it: send this message, place this call, verify this number. Alongside it, provider-specific routes stay first-class, namespaced as such, with no pretence of portability. In my service Twilio is the first complete adapter and also keeps its own routes for A2P, TrustHub, TaskRouter, recordings, usage and the rest. Pretending those routes do not exist does not make you portable. It makes you wrap the SDK in a worse SDK. What keeps the two tiers honest is that when a workflow uses a provider-specific route, that dependency is visible in one place, priced in, and chosen on purpose, instead of being smeared invisibly through the codebase. ## The hard part is inbound Sending through many providers is the easy half. Each SDK’s send call maps cleanly onto a capability method. The hard half is everything the providers send you. Message status webhooks, inbound SMS, call events, each with its own payload shape, retry behavior and signature scheme. The layer has to fold all of it into internal events with one vocabulary, because a delivery failure should look identical to product code regardless of which carrier reported it. Three rules that survived production: Verify every webhook signature at the edge, using the exact URL, body or parsed parameters required by that provider’s scheme, before triggering business logic. Reverse proxies and body parsing can change what a verifier sees. Make handlers safe for duplicate and out-of-order webhooks. Do not assume every event is guaranteed to arrive: [Twilio’s retry behavior](https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides) depends on the webhook and connection settings. Persist the event before acknowledging it, then reconcile missing terminal states. Map status vocabularies explicitly, including the statuses one provider has and others do not. Preserve the raw provider status alongside the normalized event. Route unfamiliar values to an explicit unknown state and alert on them, so you can add a mapping and replay the stored event without losing evidence. That last rule, unknown things fail explicitly instead of silently falling back, is the same rule our voice-agent runtime profiles enforce for providers and models. Silent fallbacks are how multi-vendor systems rot. ## What it buys you Outage failover, within real constraints. A common API makes changing the route easier, but the alternate provider still needs suitable numbers, credentials, capacity and registrations. It cannot move an active call, port an inbound number instantly or transfer a messaging campaign’s approval by configuration. Leverage and regional fit. The ability to actually move traffic is the difference between a negotiation and a renewal. It also lets you pick per-market winners, because carrier quality and pricing are regional in ways a single-vendor setup flattens. One place for policy. Once every message and call goes through one layer, that layer is where compliance controls live as code: banned-number and do-not-call filtering, quiet hours, per-tenant rate limits, the [calling-reputation hygiene](/blogs/stir-shaken-cnam-and-why-nobody-answers-your-ai-calls) I wrote about last time. With direct SDK usage every team reimplements those individually, or forgets to. Cheap extension. Adding a provider means implementing the capability interfaces you need and registering the adapter. In my own service, Vonage, Plivo and MessageBird exist as explicit stubs for exactly that reason: an unimplemented capability on a provider is a stated fact, not a runtime surprise. ## A timeout is not permission to send twice The dangerous case is an outbound request whose response never arrives. The carrier may already have accepted the call or message. Sending it through a second provider can create two real calls for one product action. Give each product action a stable operation ID and persist its provider, attempt and provider reference. Distinguish a confirmed rejection from an unknown acceptance state. Reconcile the latter through status queries or callbacks before retrying; use provider idempotency support where available, without assuming every API has it. The same distinction belongs in the contract: | Result | Meaning | Routing consequence | | ---------------------- | ----------------------------------------- | ------------------------------------------------------ | | Unsupported capability | This adapter cannot perform the operation | Reject before making a provider request | | Confirmed rejection | The provider did not accept the operation | Consider an eligible fallback after checking the error | | Accepted | The provider has an operation reference | Track this attempt through its lifecycle | | Acceptance unknown | A timeout left the outcome ambiguous | Reconcile before issuing another operation | This is where a useful abstraction earns its complexity. A shared method name is easy. A shared meaning for “did we place the call?” is the hard part. ## When not to do this If you have one provider, one use case and no volume, this is ceremony. Use the SDK and ship. The layer starts paying rent when the second provider shows up, or the second region, or the first serious outage. By then it is much cheaper to have built the contract early. Which is, I notice, the same lesson as every other post in this series. The contract you define before you need it is the one that saves you the rewrite when you do. --- ### STIR/SHAKEN, CNAM, and Why Nobody Answers Your AI's Calls - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/stir-shaken-cnam-and-why-nobody-answers-your-ai-calls - Summary: Your agent can be flawless and still show up as Spam Likely. What I learned about caller identity and number reputation running outbound voice agents. - Published: 2026-09-05T00:00:00.000Z - Topics: Telephony, STIR/SHAKEN, CNAM, Outbound Calling, Voice AI, Compliance Here is a failure nobody designs for. You build a good outbound voice agent. Latency is tight, turn-taking is smooth, the script converts when someone actually talks to it. And your answer rate is terrible, because every call lands on the other person’s screen as “Spam Likely.” The best conversation in the world loses to a call that never gets picked up. Building the telephony layer for Spyne’s voice agents across Twilio, Vonage and Telnyx taught me that outbound calling is two products. One is the conversation. The other is the identity of the call itself, and it is decided before anyone hears a word. This post is about the second one: the STIR/SHAKEN and CNAM alphabet soup, carrier reputation, and what you can actually do about any of it. ## Who is judging your calls In the US, whether your call shows up as your business name, a bare number, or a spam warning is decided by a loose federation you mostly cannot see. The carrier that originates your call and signs it. The carrier that delivers it. And third-party analytics engines the big carriers rely on to score calls, looking at things like volume and velocity, how often calls get answered, how long they last, how often people complain, and how long your numbers have been behaving. The mental model that took me longest to accept: there is no place to get approved. Your call’s presentation is a probabilistic verdict, per carrier, per call, shaped by everything your numbers have done recently. You do not pass this once. You maintain a reputation inside it, continuously, forever. ## What STIR/SHAKEN does, and what it does not [STIR/SHAKEN](https://www.twilio.com/docs/voice/trusted-calling-with-shakenstir) is a framework for authenticating caller identity on supported IP call paths. The originating carrier signs each call with an attestation level. A means “we know this customer and they are authorized to use this number.” B means “we know the customer but cannot fully vouch for the number.” C means “this call entered our network, that is all we know.” Two things worth internalising. It authenticates. It does not endorse. Full A-attestation means the signing provider asserts that it knows the caller and their right to use that number; it is not an absolute guarantee about the entire call path. It does not mean your call is wanted. A spammer dialling from numbers they legitimately bought gets A-attestation too. So attestation is hygiene you must have, not a cure for spam labels. I have seen teams get A-attestation sorted and then be baffled that nothing changed. Your attestation depends on your provider relationship. Verify the attestation on your actual routes. Using numbers from the originating provider can simplify verification, but onboarding and number authorization still matter. Bring-your-own-number and reseller arrangements need explicit verification too; they do not automatically imply a lower attestation. ## CNAM is not what people think Traditional US [CNAM](https://www.twilio.com/docs/voice/brand-your-calls-using-cnam) is generally a terminating-side name lookup, distinct from a SIP display name or branded-calling identity. The terminating carrier queries a database of its choosing, with caching and inconsistency to match. You register a name with your provider, and what actually displays varies by carrier and can lag your changes by days. It helps. Register it everywhere you can. But treat it as best effort, not as identity. The newer branded-calling programs, where a verified name, logo and call reason go through the carriers, are the stronger version of this, and worth evaluating as they mature, at a real per-call price. ## Reputation is the part you actually control Since there is no switch to flip, the work is operational. What has mattered in practice: Register your numbers and your traffic through the appropriate business or provider program. The [Free Caller Registry](https://www.freecallerregistry.com/fcr/) sends registration data to First Orion, Hiya and TNS. Its terms restrict submissions to businesses calling on their own behalf; a platform registering client numbers must contact the providers directly. Registration does not guarantee label removal and does not set CNAM. Ramp legitimate traffic gradually so you can observe complaints, failures and answer rates before expanding. There is no published universal “safe calls per number” threshold, and a warm-up schedule does not make unwanted traffic acceptable. Do not churn numbers to dodge labels. Rotating through numbers as they get flagged is called snowshoeing. It can spread a reputation problem across the pool while leaving the cause untouched. If a number earns a label from bad traffic patterns, the fix is the traffic pattern. Make your patterns look like your intent. Short calls, high unanswered ratios and rapid redials are useful warning signs to investigate. Analytics providers do not publish a universal scoring formula, so treat these as operational signals rather than known scoring weights. This is where the AI layer and the identity layer meet. Good [answering-machine detection](/blogs/answering-machine-detection-is-turn-takings-evil-twin), retry schedules with real backoff, and honouring do-not-call and banned-number lists are reputation features, not just courtesy. We filter banned numbers before they ever reach a dialer for precisely this reason. Watch how you display, from outside. Do not rely on receiving a notification when a carrier starts labeling you. Test-dial your own numbers on real handsets across carriers, or use a monitoring service, and treat answer rate by number and by carrier as a production metric. A sudden per-carrier drop is a reason to investigate labeling, routing failures and changes in the audience or calling schedule. Local presence cuts both ways. Matching the callee’s area code may affect answer rates, but that needs testing against your audience and should not imply a local presence the business does not have. Doing it with a rotating pool of barely-used numbers recreates the churn signature from three paragraphs ago. If you use local presence, every number in the pool still needs registration, warm-up and clean traffic. ## The compliance floor For US calling, the FCC’s [2024 declaratory ruling](https://docs.fcc.gov/public/attachments/FCC-24-17A1_Rcd.pdf) confirms that AI-generated voices fall within the TCPA’s artificial-or-prerecorded-voice restrictions. That does not make every AI call unlawful; applicable consent, identification and exemption rules depend on the call. Requirements also vary by jurisdiction and use case. Have the applicable rules reviewed for the traffic you actually send, then enforce the resulting policy at dial time: consent scope and revocation, required identification, recipient-local calling hours and suppression lists. Record which policy allowed the attempt. A reputation registration is not permission to call. ## Diagnose the layer before changing it | Symptom | First check | What it does not establish | | -------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------- | | Wrong or missing business name | CNAM registration and display on the receiving carrier | That the call has poor attestation | | Low or missing attestation | Originating provider onboarding, number authorization and the actual route | That changing the display name will fix it | | Spam warning with A-attestation | Complaints, registration status and traffic patterns | That authentication has failed | | Sudden answer-rate drop on one carrier | Test calls, delivery failures and comparable traffic cohorts | That a spam label is the only possible cause | ## The takeaway Teams building voice AI obsess over the milliseconds after a call connects and ignore the machinery that decides whether it connects at all. Answer rate is a product metric with an infrastructure supply chain behind it: attestation from your provider relationships, display from registration, and above all reputation from the aggregate behavior of your own dialer. The agent gets the demo. The identity layer gets the answer rate. --- ### The Real Cost of a Voice Agent Minute - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/the-real-cost-of-a-voice-agent-minute - Summary: Our infrastructure cost fell from $0.26 to $0.10 per minute. How to meter each component, compare operating costs, and protect conversation quality. - Published: 2026-09-04T00:00:00.000Z - Topics: Voice AI, Cost Optimization, LiveKit, Vapi, Unit Economics At Spyne we took voice-agent infrastructure cost from $0.26 to $0.10 per minute, roughly a 62% reduction. The change came alongside [our migration from Vapi to LiveKit](/blogs/migrating-production-voice-agents-from-vapi-to-livekit) and subsequent provider and pipeline tuning. Those figures describe our deployment. They are not either platform’s list price, and they do not include the full engineering cost of building and operating the system. The useful part of the story is how to make the bill explainable enough to decide what to change. ## Define the minute before comparing it A connected call minute, a minute of generated speech and a minute of worker runtime are different units. An agent that speaks for twenty seconds during a sixty-second call has not necessarily bought sixty seconds of TTS. A transfer can keep more than one billable call leg alive. For an infrastructure comparison, choose a denominator such as connected call minutes and keep it consistent across both deployments. Allocate billed costs for the same period, including failed attempts and idle capacity where applicable. Separately report cost per successful outcome: a cheaper call that loses the appointment is not an improvement. Provider contracts decide rounding, minimum charges and which resources keep billing during silence. Check those details before multiplying a headline price by call duration. ## Five components, five different meters | Component | Meter to capture | What a blended rate can hide | | ------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------- | | Telephony | Billed duration by call leg, destination and direction; number rental | Transfer legs, rounding, recording and carrier extras | | Speech-to-text | Billed audio or session duration | Silence billing, minimums and duplicated streams | | Language model | Input, cached input and output tokens by model and tier | Growing conversation history, tool schemas and retries | | Text-to-speech | Billed characters, tokens or generated audio, depending on provider | Audio generated but discarded after an interruption | | Runtime and orchestration | Workers, media transport, platform charges, storage and observability | Idle capacity, minimum commitments and separate cloud services | Self-hosting a worker does not make its runtime free. Include spare capacity and the surrounding media stack. If you use LiveKit Cloud, check its [resource meters and billing](https://docs.livekit.io/deploy/admin/billing/) as well as inference charges. A lightly used deployment can have a very different compute cost per minute from a busy one. ## Reconcile the bill before tuning the agent The accounting relationship is simple: ```text infrastructure cost per connected minute = allocated infrastructure spend / connected call minutes cost per successful outcome = allocated spend / successful outcomes ``` The hard part is connecting usage to calls. Keep a call ID, tenant, use case, engine, model and provider alongside the meters. Preserve both estimated usage and reconciled billed cost; a missing usage event must not quietly count as a free call. For LLMs, separate uncached input, cached input and output before applying their rates. A growing conversation can increase input usage on each turn even when answers stay short. For speech, distinguish generated audio from played audio, while charging according to what the provider actually bills. This does not require leaving a managed platform. Vapi exposes [component cost estimates](https://docs.vapi.ai/assistants/model-intelligence/understanding-cost) and call cost fields. Inspect the data already available before assuming migration is the only way to understand the bill. Owning the pipeline gave us more control over instrumentation and execution; it was not the invention of itemized billing. ## What to change, and what to protect Removing a platform fee creates an obvious saving, but it also creates an operating responsibility. After that decision, tune the components with the largest measured contribution. Their order will vary by workload. **Voice selection.** Compare TTS providers on real phone recordings. Some calls use narrowband audio; others preserve more detail. Listen for names, numbers, pronunciation and intelligibility as well as pleasantness. Measure task completion before assuming a cheaper voice is equivalent. **Model and context.** Test a smaller model on routine turns. Trim redundant instructions and unused tool definitions. If you summarize long conversations, retain confirmed facts, tool results and unresolved commitments. Prompt caching can reduce repeated-prefix cost where the provider supports it; it does not make all conversation history free. **Generation controls.** Output caps bound how much the model can generate, but a cap that truncates a booking confirmation or tool argument is a false economy. Service tiers change the cost and latency trade. These are among the controls I have exposed in the LiveKit Agents SDKs, and they need evaluation with the same task and response-time gates as a model change. **Non-conversation time.** Use bounded idle and hold policies, maximum durations and explicit [voicemail handling](/blogs/answering-machine-detection-is-turn-takings-evil-twin). Keep enough time for callers who need to find a document or read a number. An idle timeout that cuts them off improves the bill by failing the task. **Volume terms.** Negotiate against measured usage and include minimum commitments in the comparison. A lower unit price can still produce a higher monthly bill at the volume you actually use. ## Put an operating budget next to the saving The observed difference between our two infrastructure rates is $0.16 per minute. Here is the arithmetic at illustrative volumes; these are not our traffic figures or a forecast of anyone else’s savings. | Monthly connected minutes | At $0.26/min | At $0.10/min | Difference before additional operating costs | | ------------------------- | ------------ | ------------ | -------------------------------------------- | | 10,000 | $2,600 | $1,000 | $1,600 | | 100,000 | $26,000 | $10,000 | $16,000 | | 1,000,000 | $260,000 | $100,000 | $160,000 | Subtract incremental engineering, on-call and maintenance costs, and amortise migration work over a realistic period. The break-even relationship is monthly additional operating cost divided by saving per minute, assuming the rates and quality hold at that volume. Avoid double-counting compute already included in the infrastructure rate. This is why a managed platform can be the better economic choice at low volume, even when its per-minute fee looks larger. ## Keep quality in the same report Compare the same use cases, destinations and call-duration mix before and after a change. Put task completion, transfer success, premature interruptions and p95 response gap next to cost. Otherwise a shorter, less useful conversation can look like an optimization. Some savings are worth spending back on [turn-taking](/blogs/turn-taking-is-the-hardest-problem-in-voice-ai), reliable tool execution or a clearer voice. Ten cents was our result. Knowing which costs support a successful conversation is the part another team can reuse. --- ### Answering-Machine Detection Is Turn-Taking's Evil Twin - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/answering-machine-detection-is-turn-takings-evil-twin - Summary: Before a voice agent can decide when to speak, it has to decide whether anyone is there. Why AMD is hard, and why you cannot test it by calling your own phone. - Published: 2026-09-03T00:00:00.000Z - Topics: Voice AI, Telephony, AMD, Outbound Calling, LiveKit A voice agent doing outbound calls has to answer a question before any of the interesting ones. Not “what should I say” or “is the caller done talking,” but “is there a person here at all?” Inbound calls usually make this less urgent, although forwarded calls, automated callers and IVRs can still put a machine on the line. The moment you start dialling out at any volume, answering-machine detection becomes one of the highest-leverage pieces of the whole system, and I almost never see it discussed. Our agents at Spyne dial real people all day. Lead follow-ups, appointment confirmations, callbacks. A big share of those dials never reach a human. What the agent does in the first three seconds after the call connects decides whether the whole thing feels like a professional operation or a robodialer, and whether we are paying full pipeline cost to have a conversation with a greeting. ## Why it matters more than it sounds Miss a machine and the agent delivers its opening line to a voicemail box, then stands there waiting for a reply from nobody. Multiply that by a few thousand dials and you are burning STT, LLM and TTS minutes on silence, and leaving half-finished nonsense in people’s voicemail. I have listened to some of these. They are not flattering. Miss in the other direction and it is worse, because it happens to a person. They pick up, say hello, and get dead air while the classifier makes up its mind. Or the call just drops, because the system decided they were a machine. Every one of those is someone who now believes your business robocalled them and hung up. The cost pushes you one way. The human experience pushes the other. You cannot configure your way out of that tension; it has to be engineered. ## The signals, such as they are Nothing on a phone line announces “this is a recording.” You infer it. The strongest cheap signal is how long the first stretch of speech is before a pause. People answer short and rising: “Hello?” Machines play a long, fluent, uninterrupted sentence: “Hi, you’ve reached...” That works most of the time. Then a receptionist answers with a scripted 15-word greeting, or someone’s voicemail just says “leave a message,” and it does not. The second signal is what happens when you say nothing. A person who said hello and hears nothing says “hello?” again. A recording keeps going on its schedule. The interactivity itself is a probe, if you can afford the second it costs. The third is the end of the greeting. A beep is useful evidence, but not every mailbox emits one and not every tone is a mailbox beep. Twilio’s [AMD API](https://www.twilio.com/docs/voice/answering-machine-detection) distinguishes greeting endings detected through a beep, silence or another signal, and can also return `unknown`. Message playback needs a bounded greeting-end policy, not an indefinite wait for a tone. Sometimes the carrier tells you the call diverted. Often it does not, and prerecorded intercepts (“the number you have dialled...”) add a third category that is neither a person nor a mailbox. Implementations combine audio classification, timing rules and sometimes transcript cues, and the platforms expose AMD with exactly the knobs you would expect: how long to listen, how aggressive to be, whether to wait for the beep. Whatever the implementation, the shape of the problem is the same. You are trading confidence against dead air for real people. That is [the endpointing trade](/blogs/turn-taking-is-the-hardest-problem-in-voice-ai) in a different costume, which is why I think of AMD as turn-taking’s evil twin. ## Detection is half of it The other half is what to do, and that belongs in configuration rather than buried in agent code. In our platform, voicemail behavior is part of the runtime profile that describes every call, next to transports, transfer rules and duration limits, because different use cases genuinely want different answers. Hang up and try later. One option for follow-ups when a message would not be useful and another attempt is permitted. The interesting part is the retry schedule: backing off, varying the time of day, capping attempts per lead. Redial behavior is also a compliance and reputation question, which I will get to in another post. Leave a message. Useful for some confirmations and callbacks. This path needs greeting-end detection, a message written for voicemail rather than for a conversation, and a record that a message was left, so the next touchpoint does not repeat it word for word. Classify the failure before retrying. A confirmed invalid destination can be suppressed; a busy line, temporary network failure or ambiguous intercept needs a different policy. An AMD guess alone should not permanently ban a number. And when the classifier says human, the opening line has to work for someone who has already said hello twice. Openings that survive an awkward start do better than ones that assume a clean pickup. ## Unknown is a result, not an exception Keep classification separate from the action it triggers. An `unknown` result should not silently become “machine.” Give it a bounded fallback appropriate to the use case, and log the decision so you can audit human calls that ended early. Synchronous detection delays the opening while it listens. Asynchronous detection lets the call proceed, but its result can arrive after the agent has started speaking. That result must be checked against current call state before it stops playback, starts a voicemail message or ends the call. A late callback after a human transfer should not hang up the transferred conversation. ## The part everyone skips You cannot test AMD by calling your own phone. The failures live in the variety: hundreds of voicemail greetings of different lengths, accents and hold music, beeps at different delays and pitches, carrier intercepts, silent pickups, people answering mid-sentence to their kids. A lot of why I built my [telephony simulator](https://github.com/piyush-gambhir/telephony-voice-simulator) was this. It plays scripted callers and answering-machine scenarios at the agent over real carrier paths, and grades what the agent did. AMD is a statistical component, so the test has to be statistical too. Not “did it pass,” but “what is the false-human rate and the false-machine rate across the corpus this week, compared with last week.” Define the denominators: false-human rate is machines labeled human divided by labeled machines; false-machine rate is humans labeled machine divided by labeled humans. Track unknown rate and decision latency separately, so abstaining on difficult calls cannot masquerade as better accuracy. Once those numbers are on a dashboard, tuning turns into engineering. Before that it is folklore. ## What it taught me AMD looks like a small telephony detail. It is actually a compressed version of everything hard about voice: ambiguous audio, a real-time deadline, errors that cost different amounts in each direction, and a policy layer that product genuinely cares about. The useful question is not whether AMD is enabled. It is how often each decision is wrong, how long it takes, and what happens to the person on the line when it is wrong. --- ### Turn-Taking Is the Hardest Problem in Voice AI - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/turn-taking-is-the-hardest-problem-in-voice-ai - Summary: Callers forgive a mediocre answer long before they forgive being interrupted. Notes on endpointing, latency and barge-in from real phone lines. - Published: 2026-09-02T00:00:00.000Z - Topics: Voice AI, LiveKit, Turn Detection, Latency, Speech Ask someone what makes a voice agent good and they will talk about the model. Does it understand, does it say the right thing, does it hallucinate. Run one on real phone calls for a while and you find out that even a capable model cannot hide bad timing. What they notice is being talked over. What they notice is finishing a sentence and hearing nothing for two seconds. The thing that decides whether an agent feels like a person or like an IVR is not what it says. It is when. I have been building voice agents at Spyne for about two and a half years now, first on a managed platform, [now on LiveKit Agents](/blogs/migrating-production-voice-agents-from-vapi-to-livekit), and turn-taking is the problem I keep coming back to. This is what I have learned about it, most of it the hard way. ## The problem in one sentence Humans manage turn-taking with prosody, grammar, eye contact and a lifetime of practice, and we still talk over each other constantly. A voice agent gets one mono audio stream over a phone line and a few hundred milliseconds to make the same call. ## Silence is a bad signal A common starting point is a silence threshold. The caller is done when they have not made noise for N milliseconds. It is wrong in both directions at the same time. Make N short and you cut people off. Callers pause to think. They pause to find a document. Someone reading out a VIN or a phone number pauses between every group of digits, and an eager agent will jump in at every single gap, which is exactly as infuriating as it sounds. Make N long and every exchange in the whole conversation inherits that delay. The caller finishes, the line goes quiet, and the agent sounds slow even when the model answered in 300ms. There is no correct N. Whether a pause is the end of a turn depends on what was just said, and silence cannot tell you that. ## Make endpointing care about the words The fix is to make the endpointing decision content-aware. “What is a good callback number?” followed by six digits and a pause is probably not a finished turn. “Yeah that works, book it” almost certainly is. LiveKit supports [context-aware turn detection and adaptive endpointing](https://docs.livekit.io/agents/logic/turns/tuning/). The decision can use what was said as well as how long the caller has been silent. The available detectors and tuning options depend on the SDK version and pipeline. In my experience this one change does more for how an agent feels than a faster model does. I care about this layer enough that I have spent time prototyping my own detectors against the LiveKit Agents framework, including one that asks an LLM to score whether the turn is complete, and a hook for plugging custom audio-based detectors into the pipeline. Neither is something I would have been able to try on a closed platform. That is a large part of why I like working on an open one. ## The latency budget Turn-taking and latency are the same problem from two angles. From the moment the caller stops talking, everything downstream is on the clock: the endpointing delay itself, STT finalising, the model’s time to first token, TTS time to first byte, and the network in between. The acceptable gap depends on the task: a quick acknowledgment and a database lookup have different expectations. Measure the full gap the caller hears, not just the model’s response time. Stream model output into speech and play audio as it becomes available. Treat generation from an unfinished user turn as speculation: cancel it when the transcript changes, and keep irreversible tools behind a committed turn. LiveKit calls this [preemptive generation](https://docs.livekit.io/agents/multimodality/audio/). Model choice and service tier can affect latency; output caps mainly bound generation and do not guarantee a faster first token. Part of what I have upstreamed to the LiveKit Agents SDKs is exposing exactly those OpenAI Responses API controls, because in a voice pipeline they are latency features first. Do not add artificial delays just to sound human. When a tool really takes time, a brief acknowledgment such as “let me check that” can explain the wait. Repeated filler on every turn becomes its own annoyance. ## Barge-in is the other half Endpointing decides when to start talking. Barge-in decides when to stop, and it is harder. When the caller talks over the agent you have to notice fast, which means running voice activity detection on the inbound stream while the outbound stream is playing, which on a phone call also means dealing with the echo of the agent’s own voice. Then you stop the audio, flush whatever TTS is queued, and cancel the in-flight generation, otherwise the agent comes back after the interruption and finishes answering the old question. Then there is the part nobody warns you about. The agent said half a sentence. Did the caller hear the confirmation number or not? Your conversation state has to record what was actually played, not what was generated. I have watched agents confidently “repeat” something the caller never heard. And not every noise is an interruption. “Mm-hmm,” “okay,” “right” are the caller telling you to keep going. An agent that stops dead for every acknowledgment feels broken in a way that is hard to describe but instant to notice. ## The phone makes all of it worse Everything above gets harder on a real call. 8kHz narrowband audio. Speakerphones in cars. Showroom noise in the background. Carrier jitter. Answering machines whose greetings are designed to sound like a person picking up. If your test setup is a laptop microphone in a quiet room, you have not tested turn-taking. This is most of why I ended up building a [telephony simulator](https://github.com/piyush-gambhir/telephony-voice-simulator) that plays scripted, imperfect callers at the agent over real carrier paths and grades what it does. Turn-taking regressions do not show up in unit tests. They show up as a slow drift in how many callers hang up. ## Measure the failures separately A single average latency hides the trade you are tuning. Keep a labeled set of complete turns, mid-sentence pauses, digit sequences, backchannels and genuine interruptions. Split results by language and audio conditions. | Measure | Definition | Why it matters | | ----------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | Response gap | Last caller speech sample to first audible agent sample | Includes endpointing, generation and transport; track p50 and p95 | | Premature reply rate | Replies started before a labeled user turn ended, divided by labeled turns | Exposes an agent that is fast because it cuts people off | | Interruption stop time | Genuine interruption onset to the last audible agent sample | Measures playback stopping, not just a cancellation event | | False interruption rate | Backchannels or noise that stopped playback, divided by labeled non-interruptions | Catches an agent that cannot finish a sentence | Replay these cases after changing a detector, STT provider or audio buffer. Listening finds the failure; a labeled corpus makes the regression repeatable. ## What I would tell someone starting today Make endpointing semantic. A turn-detection model with adaptive thresholds is the highest-leverage component in the pipeline. Budget latency end to end, stream where useful, and measure provider settings against real calls. Treat barge-in as state management, not just audio. Track what the caller actually heard. Keep talking through “mm-hmm.” Acknowledge a real wait without adding one. Test against bad audio over real carriers, and grade it automatically, so listening sessions have repeatable checks behind them. None of this shows up in a model comparison table. That is roughly why it is where the difference is. The teams whose agents feel human do not have a secret model. They took turn-taking seriously. --- ### Migrating Production Voice Agents from Vapi to LiveKit - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/migrating-production-voice-agents-from-vapi-to-livekit - Summary: We ran dealership voice agents on Vapi and now run them on LiveKit. What we did before the migration so it would not become a rewrite, and what it did to cost. - Published: 2026-09-01T00:00:00.000Z - Topics: Voice AI, LiveKit, Vapi, Telephony, Architecture Our voice-agent infrastructure cost went from $0.26 to $0.10 per minute, a reduction of about 62%. Those are our deployment figures, not a Vapi-versus-LiveKit price comparison: the migration also gave us room to change providers and tune the pipeline. They describe infrastructure spend, not the engineering and operational cost of owning it. Most of that gap is one decision: moving our production voice agents off Vapi and onto LiveKit. I have wanted to write this down for a while, partly because I kept looking for a post like it when we were deciding and never found one, and partly because the most useful thing we did was not the migration. It was the thing we did a year before it. ## Where we started I built Spyne’s conversational AI stack on Vapi from nothing. Voice agents that pick up dealership calls, qualify leads, run follow-ups, over real phone numbers, with real people on the other end who do not know or care that they are talking to software. Vapi was the right call. I want to say that plainly, because posts like this tend to read as “we escaped.” We did not escape anything. When you have zero agents in production and a business waiting on them, a managed platform gives you media transport, telephony, STT and LLM and TTS orchestration, interruption handling and a dashboard, on day one, for a per-minute fee. That trade is excellent right up until it is not. ## Why it stopped being enough Three things, in the order they started bothering me. The first was debugging. The incidents that matter in voice are never “the call failed.” They are “the agent went quiet for two seconds at 4pm on one call and the customer hung up.” To answer that you need per-stage latency, the transcript lined up against the audio, and the ability to follow one utterance through STT, the model, and TTS. Vapi provides [recordings, transcripts and call logs](https://docs.vapi.ai/assistants/call-recording). Our limit was how far we could instrument and change the execution path behind them. We wanted to follow a symptom into the worker, reproduce it, and test a fix ourselves. The second was the conversation itself. Where a voice agent feels good or bad lives deep in the pipeline: how it decides the caller is done talking, how it handles being interrupted, what it does with a half-generated sentence when the caller cuts in. On a managed platform those are settings, if they are exposed at all. Our differentiation was conversation quality, and I could not get at the parts that determined it. The third was the bill. A platform fee is small per minute and irrelevant at low volume. At production volume it was our single largest controllable line item, sitting on top of the model and telephony costs we were paying anyway. None of these was decisive on its own. Together they were. ## The thing we did a year earlier While we were still fully on Vapi, we rebuilt the platform around a split: a control plane that owns tenant configuration and call state, and stateless workers that execute calls. Every call is described by a typed runtime profile. Providers and models, prompts, tools, transport, recording, voicemail behavior, idle handling, duration limits, transfer rules, reporting. Workers take a profile in and send lifecycle events out. The events are idempotent and signed, and the pipeline is traced with OpenTelemetry so a call looks the same in our tooling no matter what ran it. At the time this was mostly about hygiene. It turned out to be the whole migration. The LiveKit worker never had to imitate Vapi. It had to satisfy a contract we had already been running in production for months, and product code, dashboards and reporting did not know which engine was underneath. If you take one thing from this post: do not migrate from one engine to another. Write your own contract first, make your current engine satisfy it, and then the migration is adding a second worker instead of doing a rewrite. ## How the cutover actually went Boringly, which is the point. We built the LiveKit worker against the existing contract. Same profiles in, same signed events out. Then we graded it before trusting it. I had built a [telephony simulator](https://github.com/piyush-gambhir/telephony-voice-simulator) for exactly this: scripted callers, answering-machine cases, IVR traversal, interruptions, and automated scoring of what the agent did. We ran both engines through the same suite. Calling the agent ourselves helped us hear problems; replayable scenarios helped us tell whether we had fixed them. Traffic moved by profile, not by flag day. Because every call is described by data, moving a tenant or a use case to LiveKit was a configuration change, and anything that misbehaved moved back the same way. And we kept the old worker around longer than felt necessary, because live traffic finds what simulators do not. Speakerphones in moving cars. Carrier quirks. Answering machines that sound exactly like a person for the first four seconds. ## What I would put in the rollout gate A migration needs acceptance criteria beyond “the call connected.” I would compare the two engines on the same scenarios and traffic cohorts: | Gate | What to compare | What should stop expansion | | ------------------- | ------------------------------------------------------------------------ | ------------------------------------------------- | | Conversation timing | End of caller speech to first audible response, at p50 and p95 | More long gaps or premature replies | | Task completion | Correct lead capture, tool results and human transfers | Lost fields, duplicate actions or failed handoffs | | Lifecycle integrity | One terminal call result, with recording and transcript references | Missing, duplicated or contradictory results | | Operations | Worker failures, carrier errors and cost using the same billing boundary | Unexplained regressions or missing telemetry | Set thresholds from the existing deployment before shifting traffic. Keep the engine choice fixed for the life of a call; rollback should route new calls to the previous worker while active calls drain. Replaying a failed call must not repeat a booking or send a second follow-up. ## What actually hurt: telephony The AI pipeline was not the hard part. The phone system around it was. A managed platform hides telephony. Once you own the stack, you own SIP trunking into LiveKit, and you own everything we had already learned running the telephony layer across Twilio, Vonage, and Telnyx: caller ID and CNAM, STIR/SHAKEN attestation, answering-machine detection, cold and warm transfers to humans, the compliance machinery around outbound calling. None of it is exotic. All of it is production-critical, every carrier has opinions, and it needs real engineering time. If your team has never operated SIP, that is the half of the project to staff first. ## What actually hurt: turn-taking Owning the pipeline means owning the hardest problem in voice, which is knowing when the caller is done. Silence thresholds are wrong in both directions at once. LiveKit’s turn-detection model is a good default, but tuning end-of-turn, interruptions and filler handling against your real call distribution is where the quality comes from, and it is work. It is also where owning the stack paid for itself. When we needed pipeline behavior that did not exist, we could read the framework and change it. That habit turned into pull requests. I now have merged changes in both the Python and Node LiveKit Agents SDKs. They expose OpenAI Responses API controls we needed for latency and cost, and keep the caller’s number on Twilio warm transfers. The distance from platform user to platform contributor is a lot shorter than it looks from the managed side. ## Was it worth it Per minute we pay a bit under 40% of what we paid before. The main gain is that we can investigate and tune the parts of a conversation we used to struggle to reach. The cost took a spreadsheet to justify; the control took one incident. I would not tell everyone to do this. If you are before product-market fit, if call volume is low, or if nobody on the team wants to own a phone system, stay on the managed platform. The fee is cheaper than the engineering, and it buys you the time to find out what your product actually needs. Move when conversation quality is the product, when per-minute economics dominate your unit costs, or when you notice your support tickets are really feature requests for pipeline internals. Either way, start with a small contract around configuration, call identity and lifecycle events. It takes work while you are managed, but it gives the next engine a boundary to implement. For the economics behind our decision, I wrote up [how to account for a voice-agent minute](/blogs/the-real-cost-of-a-voice-agent-minute). --- ### Issue Tracking Is Dead. The Ticket Is Just a Receipt. - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/issue-tracking-is-dead - Summary: I pulled 18 months of my own commit history to test whether tickets still do anything. Here is what the data shows, including the part that contradicts me. - Published: 2026-08-09T00:00:00.000Z - Topics: AI, Software Development, Engineering Management, Agents, Productivity Last month I opened Jira to file a ticket for something I had already shipped. The branch was merged. The fix was in production. I sat there filling in a summary, a description, an acceptance criterion, and a story point estimate for work that was finished, deployed, and already forgotten by everyone including me. I did it anyway. Because that is the process. That is when it clicked. The ticket was not an instruction. It was a receipt. Proof that a transaction occurred, generated after the fact, filed somewhere nobody will look again unless something goes wrong. ## First, the concession Issue tracking is not going away, and I am not going to pretend otherwise. Neither are receipts. That is the point, and it is the half people skip when they quote the punchy version of this argument. Something can be mandatory and still be dead. Dead does not mean unused. Dead means it stopped being the thing that does the work and became the thing you keep in case someone asks. ## Linear said it first, and got laughed at In March 2026, Linear, a company that sells issue tracking, put "Issue tracking is dead" on its own homepage. Karri Saarinen, their CEO, said trackers were built "for an era of handoffs." Their COO Cristina Cordova put it more bluntly: "process became the work." They brought numbers. Coding agents are deployed in over 75% of their enterprise workspaces. Agent-completed work volume grew 5x in three months. Nearly a quarter of all new issues are now authored by agents rather than people. Hacker News was not impressed. The thread called it awkwardly premature, rebranding dressed as revolution, a company adding LLM features and declaring a category dead to get attention. The recurring objection was some version of _but we still need a system of record_: long-term history, cross-team visibility, decisions captured somewhere durable. I think Linear is right and the thread is wrong, and I think the reason is sitting in my own git history. So I went and pulled it. ## What my commit history actually says I want to be careful about what counts as evidence here, so one thing up front: side projects prove nothing. Nobody files tickets for their own weekend repo, and never having had a tracker is not the same as a tracker dying. Issue tracking is a workplace phenomenon. It only means anything where there is an actual Jira instance, actual sprints, actual grooming, and an actual expectation that you use them. So this is all work. Fifteen repositories at my day job, everything I have authored across the last eighteen months. **1,737 commits**, every one of them produced inside a process that formally requires a ticket. Here is what happened to it. | period | commits/month | had a ticket reference | | ------------------- | ------------- | ---------------------- | | mid-2025 → Mar 2026 | 50–90 | ~23% | | April 2026 | 164 | 16% | | May 2026 | 258 | 20% | | June 2026 | 288 | 22% | | July 2026 | 301 | 31% | ## The part that contradicts me Look at that right-hand column. Ticket referencing did not collapse. It did not decline. It sat between 16% and 31% for eighteen straight months and never moved. If my thesis were "agents are killing tickets," this table refutes it. So let me state the honest version instead. **Tickets did not decline. The work outgrew them.** My throughput roughly quadrupled between March and July, from under a hundred commits a month to three hundred. Ticket coverage stayed flat. Which means the number of commits with no ticket behind them went from about 40 a month to about 230 a month. Jira was always only catching a quarter of my work. Nobody noticed, because a quarter of 60 looks close enough to all of it. A quarter of 300 does not. The tracker did not fail recently. It was always this partial. Agents just widened the gap until it was too big to keep pretending about. ## The detail that actually settles it I split those ticket references into two kinds: the ones git generated automatically, by stamping a branch name into a merge commit, and the ones an actual human typed into an actual subject line. **For ten consecutive months, I hand-wrote a ticket number exactly zero times.** Every single reference in that entire span was git, mechanically, inheriting a string from a branch name. Not one was a person deciding that the ticket number was worth communicating. Then hand-written references appear again this April, and climb: 21, then 50, then 35, then 57. Precisely when my volume quadrupled. Those are agent-authored commit messages: an agent reading the branch name and dutifully carrying the ID forward, because that is what the pattern looks like. So the sequence is: humans stopped writing the ticket number over a year ago. Git wrote it for ten months. Now an agent writes it. **The only entity that reliably writes the ticket number is a machine.** The ID survives by being copy-pasted forward. That is not information. That is a fossil. I have a repository directory named after a ticket number to prove it, a folder that has now long outlived whatever it once described. One honest limit: commit references are a proxy for Jira usage, not a measurement of it. There is plenty of ceremony that never touches a commit message. What this data proves is narrower and still damning: **the ticket has no connection to the code.** ## Why the ticket stopped meaning anything A ticket was never a to-do item. It was a vehicle. It carried knowledge across a gap, from the person who knew what was needed to the person who was going to build it. That gap is the entire reason it had to be written down, stored, assigned, prioritized, and tracked. The ceremony existed because the information had to survive a journey between two humans who could not see each other's screens. Now the person who knows and the thing that executes are inside the same loop. Nothing travels. So the ticket has no cargo. You are still addressing the envelope and still mailing it to yourself, about something already done. That is why it feels stupid. Not because it is slow. Because **it is empty**. ## What actually died: filing, not rigor Here is the part that keeps this from being another "process is dead" post by someone who has never maintained anything. I did not stop writing tickets. I write _more_ specification than I ever have. Constraint documents. Acceptance criteria. Context files. Handoff notes. Some days I write more words of intent than lines of code, and the quality of that writing is now the single biggest lever on whether the output is any good. Rigor did not die. **Filing died.** The tracker was always two products bolted together: a place to think, and a place to store. Agents made the thinking half more valuable than it has ever been. A vague spec now produces confidently wrong code at 300 commits a month instead of quietly stalling. And they made the storage half worthless, because a spec that gets consumed in ten minutes has a shelf life measured in hours. Jira is a warehouse for things that no longer keep. The artifacts that replaced it all share one property: they are versioned _with_ the code instead of running parallel to it. The spec in the repo. The pull request. The commit message. And increasingly, a working file that holds the current state of a task and gets overwritten rather than appended, then deleted when the work lands. That last one is the tell. It is a ticket in every functional sense: intent, constraints, status, next action. It just has no ID, no assignee, no column, and no afterlife. I did not set out to build a Jira replacement. I built one by accident, and then noticed what I had left out. ## Answering the objection properly The strongest counter, the one that ran through that whole Hacker News thread, is organizational memory. History, cross-team visibility, decisions captured somewhere durable, an audit trail when someone asks what happened in Q2. I concede all of it. Completely. And notice what every single one of those is. History. Audit. Visibility. Traceability. Not one of them is _building anything_. Every item on that list is a record kept against a future question: what happened, who decided, why it cost that much, was anyone paying attention. That is the definition of a receipt, and it is a real reason to keep one. That is not a rebuttal to my argument. That is my argument, stated by the people who disagree with me. There is a real cost to what replaced it, and I will name it: ephemeral state means no archaeology. Six months from now, nobody can reconstruct _why_. That is a genuine loss and I do not have a clean answer for it yet. But I would rather solve that problem honestly than keep pretending the board was ever doing it. ## What is actually ending Jira was never a productivity tool. It was a translation layer between people who could not see each other's screens. Its value has always scaled with the number of people who are not in the room. At two hundred engineers that number is large and the tax is worth paying. But agents are steadily shrinking how many people need to be in the room for any given piece of work. A feature that used to move through a product manager, two engineers, and a reviewer now moves through one person and a loop. The tax does not disappear. The taxable population does. That is what those 230 untracked commits a month actually are. Not sloppiness. Just work that no longer has a gap wide enough to need a ticket to cross it. So keep the tracker. Keep the audit trail. Stay compliant. File the receipt. Just stop confusing the receipt with the purchase. Nobody has ever built a company out of its filing cabinet, and right now there are people whose entire job is the filing cabinet. That is the part that is actually ending. --- ### What Is the Singularity? Why Sam Altman and Elon Musk Say We Are Already In It - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/what-is-the-singularity - Summary: What the singularity means, why Altman and Musk argue it has already started, and which parts of that claim survive contact with the evidence. - Published: 2026-08-03T00:00:00.000Z - Topics: AI, AGI, Singularity, LLMs, Sam Altman, Elon Musk, Tech Culture Elon Musk posted five words on X in January: "We have entered the Singularity." In July, on the _Relentless_ podcast, Sam Altman said much the same thing. "We are now, like, in the singularity." Musk [quoted him approvingly](https://www.forbes.com/sites/ashishbhatia/2026/07/28/sam-altman-says-were-in-the-singularity-what-does-he-actually-mean/) within the week. So two of the most powerful people in technology are telling you that the biggest event in human history is happening right now, while you read this. I went and checked. The short version: the word has a precise meaning, both men are using it loosely, and there is exactly one piece of evidence in this whole argument that I think is worth losing sleep over. It isn't the one they keep pointing at. ## What Is the Singularity? The singularity is the point where AI becomes good enough to improve itself, and each improved version builds a better one faster than the last, until progress outruns anyone's ability to predict or control it. The name is borrowed from physics. At the center of a black hole, the equations stop producing useful answers. Same claim here. Past a certain point, forecasts stop working, because the future is being designed by something that thinks faster than the people doing the forecasting. The mechanism is four steps: 1. Build an AI that is good at AI research. 2. It designs something better than you could have designed yourself. 3. That version is better at step two than the one before it. 4. Repeat, faster each time. That loop is the whole idea. Robots, abundance, mass unemployment, extinction: all of it is downstream guesswork about what happens once the loop starts spinning. If the loop isn't spinning, none of the rest follows. ## Who Came Up With the Idea This isn't a Silicon Valley invention. It's a 68-year-old argument that keeps getting rediscovered. | Year | Who | What they added | | -------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **1958** | John von Neumann, via Stanisław Ulam | First recorded use in this sense. Accelerating progress approaching "some essential singularity" past which human affairs could not continue as we knew them | | **1965** | I. J. Good | Named the mechanism, calling it an intelligence explosion. His line: the first ultraintelligent machine is "the last invention that man need ever make" | | **1993** | Vernor Vinge | Wrote the essay that fixed the term in the culture, and gave it thirty years | | **2005** | Ray Kurzweil | Made it famous and put dates on it: human-level AI by 2029, singularity by 2045 | Vinge's thirty years ran out in 2023. Kurzweil's first date is three years away. I'm not raising this to be snide. Old ideas can turn out to be right, and being early isn't the same as being wrong. But it does tell you something that "roughly ten to thirty years out" has been the standard answer since the Eisenhower administration. ## Three Claims Wearing One Word Almost all the confusion in this debate comes down to one thing. "Singularity" is currently doing three separate jobs, and the people using it are not making the same claim. ### Claim one: the machine improves itself AI systems meaningfully building their own successors, with the cycle tightening each time. This is Good's intelligence explosion, the original meaning, and the only version of the claim that can actually be checked. It isn't happening yet, and the people claiming otherwise admit as much when you read the fine print. Altman describes what we have today as a _"larval version of recursive self-improvement"_. That's AI speeding up human researchers, which is a different thing. OpenAI's published roadmap put a research intern at September 2026 and a fully autonomous AI researcher at March 2028, and in June the company [walked even that back](https://the-decoder.com/openai-targets-full-scale-autonomous-ai-researcher-by-early-2028/), saying a significant fraction of its research might by then be done by AI working alongside human researchers. The company chasing this hardest says the milestone is two years out. So we are not past it. ### Claim two: AI passes us AI crossing some threshold, first the smartest individual human and then all of us put together, after which economics and geopolitics change shape. This is Musk's version, and to his credit it comes with dates. AI beats any single human by the end of 2026. AI beats all of humanity combined by roughly 2030. Dario Amodei has said something adjacent, putting systems "broadly better than all humans at almost all things" at 2026 or 2027. These are real predictions with real deadlines, and the first one is about five months from now. We won't have to argue about it for long. ### Claim three: everything feels fast now Progress quick enough that living through it feels different. Things that stunned you last year are boring this year and expected next year. This is mostly what Altman is describing. In his 2025 essay _The Gentle Singularity_ he called it being "past the event horizon", and was explicit that it feels gentler and less cinematic than people expect. He's right, and this claim is also nearly impossible to disprove. Nobody in 2019 would have believed where image generation, code assistance or voice synthesis are today. If your bar is "the rate at which I am astonished has gone vertical", we cleared that a while ago. Now put claim three next to claim one. Claim three is obviously true and tells you very little. Claim one would change everything and hasn't happened. They share a word. So you can make the first claim, in the vocabulary of the second, and be technically honest while sounding like a prophet. Forbes made the same observation about Altman's July remark: he's describing a continuum in language that was built for a threshold. ## Why Everyone Is Talking About It in 2026 Four things landed at once, and only some of them are about the technology. ### The evidence that actually holds up This is the part I'd pay attention to, and it gets much less airtime than the podcast quotes. [METR](https://metr.org/) measures something more useful than benchmark scores. They ask how _long_ a task a model can finish on its own, at 50 percent reliability. The answer keeps doubling on a schedule: roughly every seven months across the full history, and closer to every four months since late 2023. By February 2026 that number was about 14.5 hours. A model can take a piece of software work that would occupy a good engineer for most of a working day and finish it unsupervised, at coin-flip odds. Run the curve forward and you get month-long autonomous projects some time in 2027. A system that can hold a month-long project together without supervision starts to look like a system that could run a research programme. That's the real argument for the singularity being close, and unlike the rest of this it rests on measured data rather than vibes. It's still an extrapolation. Trends bend. But it's an extrapolation from something that has held for years, which puts it in a different category from everything else on this page. ### The feeling of building right now The thing that set Musk off in January was engineers describing months of work compressed into days. If you write software you've probably felt some version of this. It's disorienting to watch a machine finish in twenty minutes something you'd blocked out a week for. I've had that week, more than once. That feeling is real, and it's a terrible instrument for measuring civilisational change, because what it measures is your own surprise. I've written before about how [the bottleneck in software was never the typing](/blogs/the-real-bottleneck-in-software-development-isnt-writing-code), and most of what feels miraculous right now is the drafting step getting cheap. That's a big deal. It isn't an intelligence explosion. ### It has become a positioning statement You can't separate these claims from who's making them. Altman and Musk both run companies whose valuations move with how close AGI is believed to be. "We are in the singularity" raises money, attracts researchers and shapes how regulators think. None of that makes it false. Insiders do see things first. But it means the sentence is not free to say, and you should price that in the same way you would for any other executive describing their own product. The tell is who's saying what. The people with the most to gain from declaring arrival are the ones declaring arrival. People building comparable systems under different incentives sound noticeably more careful. ### Everyone needs a public number now AGI forecasting has turned into a genre. Every lab leader gets asked in every interview, so every lab leader has a date, and those dates get repeated as though they were measurements rather than opinions. ## The Case Against The counter-arguments are strong enough that brushing them off takes an actual argument. **Progress is lopsided, not general.** Demis Hassabis calls the current state _jagged intelligence_: systems that take a medal at a mathematics olympiad and then fumble something a twelve-year-old does without thinking. That's the profile of a very good, very uneven tool. Hassabis puts us in "the foothills", not inside anything. **The architecture might be a dead end.** Yann LeCun argues that language models can't get there no matter how much you scale them, because what's missing is persistent memory, world models and common-sense grounding, and those are missing components rather than missing data. He puts the fix at about a decade. Andrej Karpathy, coming at it from a very different angle, lands on a similar order of magnitude. Gary Marcus has a public 10-to-1 bet that AI won't clear his AGI bar by the end of 2027. You don't have to agree with any of them, but "the current approach is missing something and scale won't supply it" is a technical position held by technical people. **The economy hasn't noticed.** This is the one I find hardest to argue with. A genuine intelligence explosion should be the most visible economic event ever recorded. Instead, US nonfarm business productivity grew 2.1 percent in 2025, which is fine, and lower than several unremarkable years in the 1990s. It then fell to a 0.3 percent annualized rate in the first quarter of 2026. Studies through 2024 and 2025 find little sign of economy-wide job losses or falling wages despite very fast adoption. There's one loud exception and it deserves attention: employment for software developers aged 22 to 25 is down almost 20 percent from 2024. That's a real signal and I don't want to wave it away. But look at its shape. It's concentrated at the entry level of a single profession. That's a story about which rung of the ladder gets automated first. It isn't an intelligence explosion. ## So Are We In the Singularity or Not? Sorted by how well each claim survives contact with evidence: | The claim | Where it stands | | -------------------------------------------------- | --------------------------------------------------------------------------- | | Progress feels unprecedented | True, and basically nobody disputes it | | Autonomous task horizons are growing exponentially | Measured, holding for years, the strongest thing on this page | | AI is now improving itself in a closed loop | No. Conceded by Altman, roadmapped by OpenAI for 2028 | | AI passes all humans combined by around 2030 | An untested prediction from someone with a large stake in it being believed | | The economy has hit a discontinuity | Not in the data, with one sharp exception in entry-level software | Where I land: the trend line is the serious argument and the announcements are not. METR's curve is the only claim here I'd defend in a hostile room, and it genuinely unsettles me. But a curve on a chart is a hypothesis about the future, not a description of the present, and the specific loop that defines the singularity hasn't closed. The people telling you it has are describing a feeling, using the vocabulary of a mechanism, while holding equity in the answer. That leaves an uncomfortable position, which is probably the correct one. You can't rule it out. The trend says take it seriously. And the announcements are running well ahead of anything you could verify. ## What To Do About It If you build things, the practical implications land the same way whether or not the strong claim is true. **Assume the task horizon keeps stretching.** You don't need to believe in the singularity to plan for agents that work unsupervised for a day, then a week. That one is already on the board. Design your workflows so a longer leash is useful rather than terrifying, which mostly means better tests, tighter interfaces and clearer specs. **Bet on adaptability over specialization.** Every forecast here, optimistic and skeptical alike, agrees the frontier moves fast. Skills welded to one tool depreciate quickly. Judgment about what's worth building doesn't. **Watch the loop, not the podcasts.** The signal that matters is AI making real, novel contributions to AI research with no human in the loop. When it happens it'll show up in papers and model releases before it shows up in a quote. OpenAI has publicly committed to a date, March 2028. Hold them to it. **Discount claims by the speaker's exposure.** Not all the way to zero. But a forecast from someone whose valuation depends on it is a different kind of object from a measurement, and should be filed separately. **Mostly, ignore the word.** "Singularity" now carries so much baggage that it hides more than it reveals. Ask what specific capability someone is claiming, by when, and how you'd know if they were wrong. Nine times out of ten the answer to the last one is "you couldn't", which tells you what sort of claim you're dealing with. My guess is the next few years look like neither transcendence nor a popped bubble. They look like more of what we already have: capable, unreliable, lopsided systems getting better in fits and starts, gutting some jobs and barely touching others, while everyone argues about which chapter we're in. Less exciting than a singularity. Considerably harder to plan around, too, which is probably why nobody wants to say it out loud. ## Frequently Asked Questions **What is the singularity in simple terms?** The point where AI becomes good enough to improve itself, with each version building a better one faster than the last, until progress outruns our ability to predict or control it. **Are we in the singularity right now?** Not by the technical definition. The defining feature is AI improving its own successors without humans in the loop, and that isn't happening. Altman himself calls the current state a larval version of recursive self-improvement. **When will the singularity happen?** No consensus at all. Musk says AI passes any individual human by the end of 2026 and all of humanity by around 2030. Amodei has pointed at 2026 or 2027. Hassabis says the end of the decade. LeCun and Karpathy both say roughly ten years. Marcus has bet money against it happening by the end of 2027. **What is the difference between AGI and the singularity?** AGI is a capability level: a system that handles most cognitive tasks at least as well as a human. The singularity is an event that might follow, when that capability gets pointed back at AI research itself and starts compounding. **What is the strongest evidence that it's near?** METR's task-horizon data. How long a task an AI can finish alone at 50 percent reliability has been doubling roughly every four months since late 2023, hitting about 14.5 hours in February 2026. **Should I be worried about it?** The honest answer is that nobody knows, including the people who sound certain. The economically useful move is to prepare for capable autonomous agents rather than for a discontinuity, because the first is already measurable and the second is still a forecast. --- **Sources:** [Forbes on Altman's claim](https://www.forbes.com/sites/ashishbhatia/2026/07/28/sam-altman-says-were-in-the-singularity-what-does-he-actually-mean/) · [Altman, _The Gentle Singularity_](https://blog.samaltman.com/the-gentle-singularity) · [Benzinga on Musk's posts](https://www.benzinga.com/news/topics/26/01/49735340/elon-musk-says-we-have-entered-the-singularity-declaring-this-the-year-ai-becomes-smarter-than-humans-and-everything-changes-forever) · [METR task-completion time horizons](https://metr.org/time-horizons/) · [OpenAI's automated-researcher roadmap](https://the-decoder.com/openai-targets-full-scale-autonomous-ai-researcher-by-early-2028/) · [Stanford HAI AI Index 2026, Economy](https://hai.stanford.edu/ai-index/2026-ai-index-report/economy) · [S&P Global on AI and labour, 2026](https://www.spglobal.com/en/research-insights/special-reports/ai-impact-on-employment-2026) --- ### Claude Code: The AI Coding Assistant That Lives in Your Terminal - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/claude-code - Summary: What Claude Code actually does in a terminal, how it differs from an editor plugin, and where an agent that reads and edits your repo earns its keep. - Published: 2026-03-17T00:00:00.000Z - Topics: Claude Code, AI, Developer Tools, CLI, Anthropic ## What is Claude Code **Claude Code** is an AI-powered coding tool made by [Anthropic](https://www.anthropic.com/) (the company behind [Claude](https://claude.ai/)). Think of it as a smart coding partner that sits right inside your terminal or code editor: you tell it what you want in plain English, and it writes code, edits files, runs tests, and even creates pull requests for you. What makes Claude Code different from tools like [GitHub Copilot](https://github.com/features/copilot) or [Cursor](https://www.cursor.com/) is that it works as a full **agent**. It doesn't just autocomplete one line at a time: it can plan out an entire feature, write code across multiple files, test it, fix errors, and iterate until the job is done. All while keeping you in the loop for approvals. If you're interested in how Claude's AI capabilities extend beyond coding into everyday knowledge work, check out our guide on [Claude Cowork](/blogs/claude-cowork). ![Claude Code terminal interface](/images/blogs/claude-code/terminal.png) ## Where Can You Use Claude Code Claude Code isn't limited to just one place. You can use it in: - **Your terminal**: the classic command-line interface - **[VS Code](https://code.visualstudio.com/)**: as an extension right in your editor - **[JetBrains IDEs](https://www.jetbrains.com/)**: IntelliJ, PyCharm, WebStorm, and more - **Desktop app**: a standalone app for Mac and Windows - **Your browser**: at [claude.ai/code](https://claude.ai/code), no installation needed ![Claude Code onboarding screen](/images/blogs/claude-code/onboarding.png) ## What You Need Before Installing Before you get started, here's what you'll need: - **A computer running** macOS 13 or later, Windows 10 or later, or a modern Linux distribution (Ubuntu 20.04+, Debian 10+) - **4GB of RAM** or more - **An internet connection** - **A paid Claude subscription**: Claude Code is not available on the free plan. You'll need one of these: | Plan | Price | Best for | | --------------------------------------------- | ------------------ | ------------------------------------- | | **[Claude Pro](https://claude.ai/pricing)** | $20/month | Individual developers getting started | | **[Claude Max](https://claude.ai/pricing)** | $100 to $200/month | Heavy daily usage | | **[Claude Teams](https://claude.ai/pricing)** | $150/person/month | Development teams | | **Claude Enterprise** | Custom pricing | Large organizations | If you're on Windows, you'll also need [Git for Windows](https://gitforwindows.org/) installed first. ## How to Install Claude Code ### Option 1: The One-Line Installer (Recommended) This is the simplest way. It installs Claude Code and sets up automatic updates so you always have the latest version. **On a Mac or Linux**, open your terminal and paste: ```bash curl -fsSL https://claude.ai/install.sh | bash ``` **On Windows**, open PowerShell (search for "PowerShell" in the Start menu) and paste: ```powershell irm https://claude.ai/install.ps1 | iex ``` That's it. The installer handles everything. ### Option 2: Homebrew (Mac Only) If you already use [Homebrew](https://brew.sh/) (a popular Mac package manager), you can install with: ```bash brew install --cask claude-code ``` ### Option 3: WinGet (Windows Only) If you prefer [WinGet](https://learn.microsoft.com/en-us/windows/package-manager/winget/) on Windows: ```powershell winget install Anthropic.ClaudeCode ``` ### Verify It Worked After installation, check that everything is set up correctly: ```bash claude --version ``` You should see a version number printed. You can also run `claude doctor` to check for any issues with your setup. ## Your First Session ### Step 1: Open a Project In your terminal, navigate to any project folder you want to work on: ```bash cd ~/my-project ``` ### Step 2: Start Claude Code Simply type: ```bash claude ``` The first time you run this, a browser window will open asking you to log in with your [Claude account](https://claude.ai/). Once you've logged in, your credentials are saved locally: you won't need to log in again. ### Step 3: Start Talking Now you can type your request in plain English. Try something like: - "What does this project do?" - "Fix the bug in the login page" - "Add a dark mode toggle" - "Write tests for the user service" Claude reads your entire project automatically: you don't need to manually select or upload files. It understands the structure, finds the relevant code, and makes the changes. ## Key Features ### It Writes Real Code, Not Just Suggestions Claude Code does not just suggest, it acts. It reads files, writes code, runs your tests, checks the results, and iterates until the task is done. Ask it to "add authentication to this API" and it'll create the middleware, update routes, write tests, and commit, checking in with you for approvals along the way. ### It Understands Your Entire Codebase No need to copy-paste code or point it at specific files. Ask "how does the payment flow work?" and it traces through all the relevant files to give you an accurate answer. ### CLAUDE.md: Give It Persistent Instructions Create a file called `CLAUDE.md` in your project root to tell Claude about your project's conventions, build commands, architecture, or anything else it should know. Claude reads this file at the start of every session, so it always has the context it needs. ### Auto-Memory Claude Code remembers things across sessions. It saves useful context it learns (like build commands, debugging insights, and your preferences) and recalls them automatically next time. You can manage this with the `/memory` command. ### Connects to Your Tools (MCP) Through the [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol/servers), Claude Code can connect to external tools and services: query databases, manage GitHub issues, read Slack messages, access Figma designs, and more. There are 200+ integrations available. ### Plan Mode: Review Before Acting Type `/plan` to have Claude lay out its approach before touching any code. Review the plan, adjust it, and only then let it implement. Great for complex tasks where you want to stay in control. ![Claude Code terminal interface](/images/blogs/claude-code/terminal.png) ## Using Claude Code in Your Editor ### VS Code Extension If you prefer working in VS Code instead of the terminal: 1. Open VS Code 2. Go to Extensions (`Cmd+Shift+X` on Mac, `Ctrl+Shift+X` on Windows) 3. Search for **"Claude Code"** 4. Click **Install** You get inline diff viewing (see exactly what Claude wants to change), `@`-mention files, conversation tabs, and the ability to rewind changes if something goes wrong. [Install the VS Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code) ### JetBrains Plugin (IntelliJ, PyCharm, WebStorm) If you use a JetBrains IDE: 1. Open your IDE 2. Go to **Settings** > **Plugins** > **Marketplace** 3. Search for **"Claude Code Beta"** 4. Click **Install** and restart Quick launch with `Cmd+Esc` (Mac) or `Ctrl+Esc` (Windows). [Install the JetBrains plugin](https://plugins.jetbrains.com/plugin/27310-claude-code-beta-) ### Desktop App If you want a standalone app (no terminal or IDE needed): 1. Download from the official site: [macOS](https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect) or [Windows](https://claude.ai/api/desktop/win32/x64/exe/latest/redirect) 2. Install and open it 3. Select a folder to work in and start chatting ![Claude Code onboarding](/images/blogs/claude-code/onboarding.png) ### Web: No Installation Needed Don't want to install anything? Go to [claude.ai/code](https://claude.ai/code) in your browser. You get the same Claude Code capabilities without any local setup. Great for running long tasks without tying up your computer. ## Essential Commands Cheat Sheet ### Starting Sessions | What you want to do | Command | | ----------------------- | ----------------------------------------- | | Start a new session | `claude` | | Start with a task | `claude "add a search bar"` | | Quick one-off question | `claude -p "what does this function do?"` | | Continue last session | `claude -c` | | Resume an older session | `claude -r` | | Update Claude Code | `claude update` | ### Inside a Session | What you want to do | Command | | ------------------------- | --------- | | Get help | `/help` | | Manage memory | `/memory` | | Review plan before coding | `/plan` | | Manage tool connections | `/mcp` | | See usage and costs | `/cost` | | Clear conversation | `/clear` | | Change AI effort level | `/effort` | ## Tips for Getting the Most Out of Claude Code - **Be specific in your prompts.** "Add a search bar to the header that filters products by name" works better than "add search." - **Use CLAUDE.md** to document your project conventions. Claude will follow them consistently. - **Start with `/plan`** for big tasks. Review the approach before Claude starts writing code. - **Trust but verify.** Claude is very capable, but always review the diffs before committing. - **Use `claude -c`** to continue where you left off. Context carries over between sessions. ## Related Reading If Claude Code handles the developer side, [Claude Cowork](/blogs/claude-cowork) is its counterpart for everyday knowledge work: organizing files, building spreadsheets, creating presentations, and automating tasks, all without writing a single line of code. ## Resources - [Official Documentation](https://code.claude.com/docs/en/overview): The complete guide to Claude Code - [Quickstart Guide](https://code.claude.com/docs/en/quickstart): Get up and running in minutes - [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code): Install for VS Code - [JetBrains Plugin](https://plugins.jetbrains.com/plugin/27310-claude-code-beta-): Install for IntelliJ, PyCharm, WebStorm - [Desktop App Download](https://code.claude.com/docs/en/desktop-quickstart): Standalone app for Mac and Windows - [MCP Server Directory](https://github.com/modelcontextprotocol/servers): Browse 200+ tool integrations - [Claude Code Changelog](https://code.claude.com/docs/en/changelog): Latest updates and releases - [Claude Pricing](https://claude.ai/pricing): Plan details and pricing - [How Anthropic Teams Use Claude Code](https://www.anthropic.com/news/how-anthropic-teams-use-claude-code): Real-world usage from the creators - [Anthropic Blog](https://www.anthropic.com/blog): Announcements and deep dives --- ### Claude Cowork: AI That Actually Does the Work on Your Computer - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/claude-cowork - Summary: Claude Cowork runs real work on your machine rather than suggesting it. What that changes, where it holds up, and the tasks worth handing over first. - Published: 2026-03-17T00:00:00.000Z - Topics: Claude Cowork, AI, Anthropic, Productivity, Automation ## What is Claude Cowork **Claude Cowork** is an AI assistant that works directly with the files on your computer. Built into the [Claude Desktop app](https://claude.ai/download), it can read your documents, organize your folders, build spreadsheets, create presentations, and automate repetitive tasks: all on its own. Unlike regular [Claude](https://claude.ai/) chat where you ask questions and get text answers, Cowork is an **agent** that actually does the work. Hand it a folder of receipts and it creates an expense report in Excel with working formulas. Point it at messy data and it cleans, organizes, and outputs a polished spreadsheet. Ask it to create a slide deck and it delivers a `.pptx` file ready to present. It's designed for **everyone**, not just developers. If you've ever wished you had a smart assistant who could handle your tedious computer work, that's exactly what Cowork is. If you're a developer looking for the coding-focused version, check out [Claude Code](/blogs/claude-code) instead. ![Claude Desktop App with Cowork tab](/images/blogs/claude-cowork/desktop-app.png) ## How Does It Work Here's the basic flow, in simple terms: 1. **You open the Cowork tab** in the Claude Desktop app 2. **You point it at a folder** on your computer (like your Downloads, a project folder, or your Documents) 3. **You tell it what you want** in plain English: "Create a summary spreadsheet from these invoices" or "Organize this folder by date" 4. **Claude plans the work**, breaks it into steps, and gets to work: reading your files, running scripts, creating documents, and iterating on results 5. **You get finished files** (`.xlsx`, `.pptx`, `.docx`, `.pdf`) saved right to your folder The key difference from regular chatting with Claude: Cowork **runs to completion**. It does not just give you instructions, it does the actual work and delivers the finished output. ### Is It Safe? Yes. Cowork runs inside a **sandboxed virtual machine** on your computer. Think of it as a secure, isolated workspace. Even if something goes wrong, your main system stays completely untouched. The sandbox comes with common tools pre-installed (Python, Node.js, etc.) so Claude can run scripts and process data without affecting anything on your computer. ## How to Get Started ### Step 1: Download the Claude Desktop App Go to [claude.ai/download](https://claude.ai/download) and download the app for your computer: - **Mac**: Works on both Apple Silicon (M1/M2/M3/M4) and Intel Macs - **Windows**: Works on 64-bit Windows 10 and later (ARM Windows is not yet supported) Install it like you would any other app: open the downloaded file and follow the prompts. ### Step 2: Sign Up for a Paid Plan Cowork requires a paid [Claude subscription](https://claude.ai/pricing). The free plan doesn't include it. Here's what's available: | Plan | Monthly Price | Who It's For | | ---------------------------------------- | ------------- | ---------------------------------------------------- | | **[Pro](https://claude.ai/pricing)** | $20 | Individuals getting started: good for occasional use | | **[Max 5x](https://claude.ai/pricing)** | $100 | Regular users who rely on Cowork daily | | **[Max 20x](https://claude.ai/pricing)** | $200 | Power users who run Cowork heavily all day | | **[Teams](https://claude.ai/pricing)** | $150/person | Teams that want shared access and admin controls | | **Enterprise** | Custom | Large organizations with compliance needs | **Important:** Cowork uses significantly more AI processing than regular Claude chat. If you're on the Pro plan, you'll hit your usage limits faster than with normal chatting. If you plan to use Cowork regularly, the Max plan is worth it. ### Step 3: Open the Cowork Tab Once the app is installed and you're logged in: 1. **Open the Claude Desktop app** 2. **Click the "Cowork" tab** at the top of the app (next to "Chat" and "Code") 3. **Grant folder access**: Claude will ask which folder on your computer it should work with 4. **Type your task** and hit enter That's it: Claude will start planning and working. You'll see it thinking through the steps, and it'll check in with you if it needs any decisions made. ![Claude Desktop app showing Chat, Cowork, and Code tabs](/images/blogs/claude-cowork/cowork-tab.png) ## What Can You Do With Cowork ### Organize Files and Folders Got a messy Downloads folder? Point Cowork at it and say "organize these by file type and date." It can: - Sort hundreds of files into categorized folders - Rename files with consistent patterns (like `2026-03-15-invoice.pdf`) - Find and remove duplicates - Clean up old or unused files ### Create Spreadsheets This is one of Cowork's strongest skills. Ask it to: - Build Excel spreadsheets with working formulas (VLOOKUP, SUM, IF statements) - Create multi-tab workbooks with conditional formatting - Generate expense reports from receipt images - Turn raw data into organized, analysis-ready spreadsheets ### Create Presentations Need a slide deck for a meeting? Describe what you need and Cowork delivers a `.pptx` file: - Creates slides with proper layouts and headings - Structures content logically across slides - Outputs a file you can open directly in PowerPoint or Google Slides ### Process and Analyze Data - Extract structured data from PDFs and documents - Clean messy datasets (remove duplicates, fix formatting) - Run analysis: identify trends, outliers, patterns - Combine information from multiple files into a single report ### Research and Write Reports - Synthesize information from dozens of documents - Combine web searches with your local files - Create coherent, well-structured reports and summaries ### Automate Recurring Tasks Set up tasks that run on a schedule using the `/schedule` command: - **Morning briefings**: summarize new emails or updates - **Weekly reports**: compile data into a spreadsheet every Friday - **Invoice processing**: organize incoming invoices daily - **Price tracking**: check and record prices at regular intervals You can set tasks to run hourly, daily, weekly, or on weekdays only. Your computer needs to be on and the Claude app open for scheduled tasks to run. If a task is missed, it automatically runs when you open the app again. ## Connecting to Other Apps Cowork can connect to your other tools through **plugins and integrations**. The [Connectors Directory](https://claude.ai/connectors) has 50+ options including: - **Communication:** [Slack](https://slack.com/), Gmail, [Microsoft 365](https://www.microsoft.com/en-us/microsoft-365) - **Productivity:** [Google Drive](https://drive.google.com/), [Google Calendar](https://calendar.google.com/), [Notion](https://www.notion.so/), [Box](https://www.box.com/) - **Project Management:** [Asana](https://asana.com/), [Linear](https://linear.app/), [Jira](https://www.atlassian.com/software/jira), [Monday.com](https://monday.com/) - **Design:** [Figma](https://www.figma.com/), [Canva](https://www.canva.com/) - **Engineering & Data:** [GitHub](https://github.com/), [Hex](https://hex.tech/), [Amplitude](https://amplitude.com/) - **Finance:** [Stripe](https://stripe.com/), FactSet Some of these integrations are **interactive**: meaning they show live interfaces right inside Claude. For example, you can draft a Slack message, preview it, and send it without ever leaving the Claude app. Same with Figma designs and Canva projects. ![Cowork plugins and customize menu](/images/blogs/claude-cowork/plugins.png) ## Cowork vs Regular Claude Chat If you've used [Claude](https://claude.ai/) for chatting, here's how Cowork is different: | | Regular Claude Chat | Claude Cowork | | --------------------- | ----------------------------------- | ---------------------------------------------------- | | **What it does** | Answers questions, writes text | Actually does the work on your computer | | **File access** | Only sees files you upload or paste | Reads and writes files directly in your folders | | **What you get** | Text responses in the chat window | Finished files (Excel, PowerPoint, PDF, etc.) | | **How it works** | You guide every step | Runs autonomously, checks in when needed | | **How long it takes** | Quick back-and-forth | Can work through 50+ documents over extended periods | | **Scheduling** | Not available | Can run tasks on a recurring schedule | | **AI usage** | Standard | Uses significantly more (plan accordingly) | In short: **Chat is for conversations. Cowork is for getting things done.** ## For Teams and Enterprise If you're setting up Cowork for a team or organization, there are additional features: - **Private plugin marketplaces**: admins can build and distribute custom plugins for their organization - **Partner-built plugins**: pre-built integrations for enterprise tools - **Admin controls**: track plugin usage, set up approval workflows, and enforce policies - **Custom integrations**: connect to your own internal tools and APIs Learn more in the [Cowork for Teams guide](https://support.claude.com/en/articles/13455879-use-cowork-on-team-and-enterprise-plans). ## Tips for Getting the Most Out of Cowork - **Be specific about what you want.** "Create an expense report from the receipts in my Downloads folder, grouped by month, with a total row" works much better than "organize my receipts." - **Start with small tasks** to get a feel for what Cowork can do. Then work up to bigger projects. - **Check the output.** Cowork is very capable, but always review what it produces before sharing. - **Use scheduled tasks** for anything recurring. Set it once and forget it. - **Try the plugins.** Connecting Slack, Google Drive, or other tools unlocks powerful cross-app workflows. ## Related Reading If you're a developer, [Claude Code](/blogs/claude-code) is the coding-focused counterpart to Cowork: it lives in your terminal or code editor and can build features, fix bugs, write tests, and manage git workflows. ## Resources - [Introducing Cowork](https://claude.com/blog/cowork-research-preview): The official announcement from Anthropic - [Cowork Product Page](https://claude.com/product/cowork): Feature overview and demos - [Get Started with Cowork](https://support.claude.com/en/articles/13345190-get-started-with-cowork): Official step-by-step setup guide - [Navigating the Claude Desktop App](https://claude.com/resources/tutorials/navigating-the-claude-desktop-app): Tutorial covering Chat, Cowork, and Code tabs - [Schedule Recurring Tasks](https://support.claude.com/en/articles/13854387-schedule-recurring-tasks-in-cowork): How to set up automated tasks - [Use Plugins in Cowork](https://support.claude.com/en/articles/13837440-use-plugins-in-cowork): Connect to Slack, Google Drive, Figma, and more - [Cowork for Teams and Enterprise](https://support.claude.com/en/articles/13455879-use-cowork-on-team-and-enterprise-plans): Team setup and admin guide - [Cowork Plugins for Enterprise](https://claude.com/blog/cowork-plugins-across-enterprise): Enterprise plugin marketplace - [Connectors Directory](https://claude.ai/connectors): Browse all available integrations - [Download Claude Desktop](https://claude.ai/download): Get the app - [Claude Pricing](https://claude.ai/pricing): Plan details and pricing - [Anthropic Blog](https://www.anthropic.com/blog): Latest news and announcements --- ### Twilio A2P 10DLC: Brands, Campaigns, Costs, Throughput, and Compliance - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/twilio-a2p-brands-campaigns-costs-throughput-compliance - Summary: A comprehensive guide to Twilio’s A2P 10DLC system, including Brand and Campaign types, associated costs, throughput limits, and compliance requirements. - Published: 2025-08-15T00:00:00.000Z - Topics: Twilio, A2P 10DLC, SMS Messaging, Compliance ## What is A2P 10DLC and How It Works in the U.S. Carrier Ecosystem **A2P 10DLC** stands for **Application-to-Person 10-Digit Long Code**. It is a regulatory system U.S. telecom carriers implemented to **verify business messaging and protect consumers from spam** on standard 10-digit phone numbers. In essence, A2P 10DLC lets businesses send SMS/MMS from local-looking numbers (10-digit long codes) under carrier oversight and approval. - **Purpose:** Originally, 10-digit numbers were meant for person-to-person texting. Unregulated A2P (business) use led to spam and consumer distrust. Carriers created A2P 10DLC to **ensure messages are consensual and legitimate**, restoring trust in SMS. - **How it works:** Businesses must **register their identity (“Brand”) and messaging campaign (“Campaign”)** in a central registry (via providers like Twilio). Carriers review this information so they know _who_ is texting and _what type of messages_ are being sent. Only registered 10DLC traffic enjoys high deliverability and throughput; unregistered traffic faces heavier filtering and extra fees. - **Benefit:** Once registered, businesses get **higher messaging throughput and reliability** similar to short codes but with a local number feel. Consumers benefit because they can clearly identify the sender and have the required opt-out controls, reducing unwanted messages. In summary, A2P 10DLC is a framework that **requires U.S. businesses to identify themselves and comply with carrier rules** when messaging via 10-digit numbers. This ensures **messages are wanted (opted-in) and legal**, protecting end-users and improving SMS delivery rates across the U.S. carrier ecosystem. ## Twilio’s A2P 10DLC Model: Brands, Campaigns, and Messaging Services When using Twilio for A2P 10DLC, you’ll encounter three key concepts: **Brands, Campaigns, and Messaging Services**. These work together as follows: - **Brand:** In Twilio’s A2P context, a _Brand_ represents the business or organization sending messages. Registering a Brand means providing your business’s identity details (legal name, address, EIN/tax ID if applicable, etc.) so carriers can vet you as a legitimate sender. Think of the Brand as the “who” behind the messages. - **Campaign:** A _Campaign_ defines a specific messaging **use case or program** under a Brand. When you register a Campaign, you supply information about the messages’ purpose (e.g. marketing, account alerts), sample message content, and how users opt-in/out and get help. This tells carriers _what_ you’ll be sending and ensures compliance (opt-out/HELP info must be provided in the registration). Each Campaign is associated with one Brand, and **a Brand may have multiple Campaigns** (up to 5 active campaigns per Brand in Twilio’s system). - **Messaging Service:** A Twilio Messaging Service is a container to organize phone numbers and apply common settings/features for messaging. For A2P 10DLC, **Messaging Services play a crucial linking role**: each 10DLC Campaign must be tied to a Messaging Service that contains the phone number(s) used for that campaign. Essentially, you link a Campaign to a Messaging Service, and **all 10DLC-approved numbers in that service’s sender pool become associated with the campaign**. Twilio requires at least one 10DLC phone number in the Messaging Service when registering a Campaign. A Messaging Service can only be linked to **one** Campaign at a time (one-to-one mapping). **How they relate:** First, you register your **Brand** (business identity). Next, under that Brand you register a **Campaign** (use case details). As part of campaign registration, Twilio will either prompt you to select an existing Messaging Service or create a new one, and you’ll attach your phone number(s) to that service. Once the Campaign is approved, Twilio reports the linked phone numbers as A2P 10DLC-enabled for that use case. In practice, whenever you send messages via that Messaging Service (or from those numbers), Twilio tags them with the campaign and brand info so carriers recognize the traffic as your registered campaign. **Example:** Suppose ACME Corp registers Brand “ACME Corp, Inc.”, then Campaign “ACME Order Notifications”. They create a Messaging Service (e.g. “ACME SMS Service”) and add their Twilio number +1 212 555 0123 to it. Twilio links the “Order Notifications” campaign to that service. Now messages sent from +1 212 555 0123 (through the service) will carry the Brand/Campaign identifier to carriers, meeting A2P 10DLC requirements. ## A2P 10DLC Brand Types (Twilio): Sole Proprietor vs. Low-Volume vs. Standard (and Special Cases) Twilio offers a few different **Brand registration types** to accommodate various business sizes and needs. The Brand type determines requirements, costs, and messaging limits for your 10DLC campaigns. The main Brand types are: - **Sole Proprietor Brand (aka “Starter” Brand):** Intended for individuals or very small businesses **without an EIN** (tax ID). Eligibility is typically limited to users with a US or Canadian identity/address who aren’t registered corporations. This brand has the **fewest requirements**, since you don’t need to provide an EIN, and identity info is minimal (usually just personal info and a simplified verification). However, it has **strict limits:** Only _1 phone number_ can be registered to a Sole Prop Brand, and it supports at most **one campaign** (Starter campaign) at a time. Throughput and volume are low (details in next section), reflecting that it’s for very small-scale sending. _Use case:_ single-owner businesses or developers sending low-volume texts (e.g. a boutique or hobby project). - **Low Volume Standard Brand:** Meant for businesses that **do have an EIN (or foreign business registration)** but only need to send low volumes of messages. This is a “lighter” version of a Standard Brand with reduced throughput. It _does_ require a valid business identity (EIN for US companies, or equivalent for foreign), so you must create a Customer Profile with business details in Twilio’s Trust Hub. Compared to a full Standard Brand, the Low Volume Brand is easier to obtain (no extensive vetting) and cheaper, but comes with limits. You can register up to **400 phone numbers** under it (same as Standard), and potentially up to 5 campaigns (if needed), but each campaign’s traffic is capped at a low throughput (suitable for a few thousand messages per day). **Use case:** small businesses with an EIN who want to comply with 10DLC without the higher cost of full vetting, for example a local restaurant or an online boutique sending occasional promotions or notifications. - **Standard Brand:** Intended for **established businesses with an EIN** expecting higher messaging volumes. You’ll need to provide full business details (legal name, address, EIN, industry, etc.), and this Brand undergoes a more thorough vetting by The Campaign Registry (TCR) which results in a **Trust Score** (0 to 100) for your brand’s reputation. Standard Brands support up to **400 phone numbers** and up to 5 campaigns in Twilio. The big advantage is **much higher potential throughput** (tied to your Trust Score, see the later section) and higher daily messaging limits than Low Volume brands. This is suitable for companies that plan to send significant traffic (e.g. tens of thousands of messages per day or more) or run multiple distinct messaging campaigns. _Use cases:_ medium and large businesses, or any organization (including foreign companies) that needs scale, for example an e-commerce platform sending marketing blasts, or a bank sending 2FA codes and alerts. **Special Brand considerations:** Nonprofit organizations and political entities technically register as “Standard Brands” but with special designations: - **501(c)(3) Nonprofit or Government:** You’ll register as a Standard Brand but indicate nonprofit status (and may need to provide proof, like IRS 501c3 documentation). This qualifies your campaigns for special _Charity_ use case pricing ($3 monthly campaign fee and certain fee waivers). Twilio also has a separate onboarding path for Government/Nonprofit (e.g. emergency services), but fundamentally you still create a brand in the system. - **Political (US 527 org or campaign):** Political organizations (like PACs or campaigns) must go through an extra external verification via **Campaign Verify** to prove they are registered political entities. Once verified, they can register a Political campaign use-case. The brand is often just a Standard Brand marked as a Political entity. Carriers grant special throughput and pricing for verified Political campaigns (often very high throughput allowances for voter communications). _Note:_ Only eligible registered political orgs can use the Political use case, so if you’re running political-type messaging you **must** complete that vetting step. In short, **choose the Brand type based on your business status and volume**: - No EIN? → Sole Proprietor Brand (very limited volume). - Have EIN but small volume? → Low Volume Standard Brand. - Larger scale or multiple campaigns? → Standard Brand (with full vetting). - Nonprofit or Political? → Standard Brand **with appropriate verification** (for charity or political privileges). We compare these Brand types in detail in the table below, including use cases, limits, and pros/cons. ## A2P 10DLC Campaign Types and Use Cases (Standard, Mixed, Special, etc.) When registering an A2P campaign with Twilio, you must select a **Campaign Use Case type** that best describes your messaging purpose. This is essentially the “campaign type.” It’s important to choose the right type, as it determines certain fees, throughput limits, and compliance rules. Here are the main categories of campaign use cases: - **Standard Use Cases:** These cover typical business messaging scenarios. Examples include: - _Two-Factor Authentication (2FA)_: One-time passcodes or login verifications. - _Account Notifications_: Updates about account status, order status, billing, etc. - _Customer Care_: Support conversations or service messages initiated by a user. - _Delivery Notifications_: Shipping status alerts or appointment reminders. - _Fraud Alerts_: Suspicious activity alerts (e.g. bank fraud notifications). - _Higher Education_: Messages from universities/colleges to students or staff. - _Marketing_: Promotional or marketing content (sales, offers, coupons). - _Polling and Voting_: Surveys or voting polls (non-political). - _Public Service Announcement (PSA)_: Informational, non-commercial alerts. - _Security Alerts_: System or physical security breach warnings. Each of the above is a predefined campaign type. If you pick one specific use case, your campaign is considered a **“Declared” standard campaign**, meaning it has a focused purpose (often non-marketing). **Marketing** is also a standard use case, but carriers generally consider marketing/promotional campaigns higher risk for spam, so keep that in mind for content and opt-in compliance. - **Mixed Use Case (Standard Mixed):** A “Mixed” campaign allows you to **combine multiple use cases in one campaign**. For example, you might send both order confirmations _and_ marketing promos from the same number(s). With a Mixed campaign type, you must specify at least two sub-use-cases it will cover (e.g. “Customer Care + Marketing”). This flexibility comes at a slightly higher compliance scrutiny: carriers know you’re sending varied content. The **monthly fee is the same as other standard campaigns ($10)**, and throughput is similar to other standard campaigns, determined by your Brand’s trust score. _When to choose Mixed:_ if you truly cannot separate your traffic by use case or you want to reuse one number for multiple purposes. However, if your use cases are very different (say, critical alerts vs. ads) and volume is high, best practice is to use separate campaigns to avoid intermixing content (this can help with deliverability and compliance tracking). - **Low-Volume Mixed Use Case:** This is a special campaign type introduced for small businesses. It’s essentially a Mixed campaign (can send multiple types of messages) but with **low fixed throughput** and a **discounted monthly fee**. Twilio offers “Low-Volume Mixed” campaigns at **$1.50 per month**, much cheaper than the $10 standard, and specifically for those who registered a Low Volume Brand or who only need minimal messaging. The trade-off is that **throughput is capped at the lowest tier regardless of Trust Score** (we’ll detail numbers later, but roughly 3.75 messages a second combined and small daily limits). This is ideal for a small organization that sends maybe a few hundred messages a day across various purposes. _When to choose:_ if you want to minimize cost and your messaging volume is very low, _and_ you don’t want to maintain separate campaigns for different content. (Often recommended: Low Volume Brand + Low-Volume Mixed campaign for many small businesses to cover all their messaging at minimal cost.) - **Special Use Cases:** These are campaign types that carriers treat specially (often requiring pre-approval or additional verification). Key special use cases include: - **Charity / Nonprofit:** For 501(c)(3) nonprofits sending communications as part of their charitable mission. _Example:_ a charity organization sending donation appeals or program updates to subscribers. This type requires you to prove nonprofit status (e.g. EIN category and maybe IRS letter) during registration. **Monthly fee is only $3** (heavily discounted), and typically **carrier messaging fees are waived for verified charity campaigns** (no per-message carrier surcharge). Throughput is generally high (and not trust-score-limited in the same way) because nonprofits are not seen as spam risk. Choose this if you are eligible: it significantly lowers costs for genuine nonprofits. - **Emergency Services:** For certified government or emergency service entities sending _urgent public safety messages_. e.g. local government emergency alerts, 911 outbound notifications, weather emergencies. This use case is **strictly limited** to authorized senders (often government or emergency management agencies). It offers priority delivery. Monthly fee is **$5**. Carriers typically waive content restrictions for critical alerts and may allow very high throughput for emergencies. Only choose this if you truly operate in public safety: it requires verification (sometimes through Twilio’s special onboarding for government). - **Political:** For political campaign communications (to voters) by eligible organizations (e.g. a campaign, political action committee, or similar). As noted, it requires external vetting (Campaign Verify token) to prove the sender is a registered political entity. Once verified, carriers assign extremely high throughput limits for Political campaigns (to allow mass texts to constituents). Monthly fees are around the standard level ($10), but note you might incur a separate one-time fee with Campaign Verify. Only 501(c)(4/5/6) or 527 orgs can use this; it’s **not for issue advocacy by normal businesses**. If you aren’t a qualified org, you must use standard or mixed use cases instead (e.g. “Polling and voting” if running a survey, but not partisan messaging). - **Others:** There are a few less common special use cases like **K-12 Education** (for school districts, often $10/month) and **Proxy/Conversational** (for platforms that facilitate person-to-person conversations like ride-share driver ↔ passenger chats, often $10/month with special per-message fees). These are niche: e.g. K-12 is restricted to eligible educational institutions, Proxy might be used by contact centers or apps simulating P2P chats. If you need one of these, you'll know by your application (and Twilio may require additional info). **Choosing the campaign type:** The general rule is to pick the **single use case that best matches your primary messaging purpose**. If you have multiple distinct purposes, you _can_ either choose Mixed (to cover all) or register multiple campaigns (one for each use case). Multiple campaigns mean you’ll pay multiple monthly fees, but each campaign is more narrowly defined which carriers prefer. A best practice is this: **don’t abuse “Mixed” just to save money** if you truly have, say, promotional traffic and important alerts, because separating those into separate campaigns can safeguard your deliverability (e.g. if one campaign faces an issue, it won’t affect the other). Twilio’s guidance: if you have multiple use cases, register each one if feasible. However, for small senders worried about cost, the Low-Volume Mixed is a great cost-saving option to cover all message types in one campaign. We’ll cover recommendations in detail later. Below we include a comparison table of campaign types (use cases, fees, throughput, pros/cons) to help decide. ## Costs and Fees Breakdown (USD) Using A2P 10DLC involves **one-time registration fees, monthly fees per campaign, and per-message fees** (on top of Twilio’s usual messaging costs). Below is a detailed breakdown: - **Brand Registration Fees (one-time):** When you register a Brand, Twilio charges a one-time fee based on the brand type: - **Sole Proprietor Brand:** $4.50 one-time fee (Twilio’s help doc quotes $4.50, though some sources round to $4). Twilio often calls this the **Starter Brand registration fee**. If you had a legacy “Starter” brand from early 2021, Twilio converted it to Sole Prop for free, but new ones pay ~$4. - **Low Volume Standard Brand:** $4.50 one-time (roughly the same small fee). Twilio sometimes lists ~$4.41 due to minor tax variations. Essentially, it’s a token fee similar to Sole Prop since no secondary vetting is involved. - **Standard Brand:** $44 one-time. This is much higher because it includes a _primary vetting fee_ by TCR. (Typically, TCR’s base fee is $40 and Twilio adds ~$4 admin.) This fee is charged at brand approval time. - **Special cases:** Nonprofit and Government brands also use the Standard brand flow, so they pay $44 as well. For political brands, registering the Brand via Twilio is $44, _plus_ you’ll separately pay Campaign Verify (currently $95 for verification token for political entities, outside Twilio). Political and certain large brands might later choose **Secondary Vetting** (an optional deeper vetting to improve trust score) for an additional fee (around $40 to $60), but this is optional and not common unless you need higher throughput and got a low initial score. - **Campaign Registration Fees (vetting fee, one-time per campaign):** As of 2023, there is a **$15 vetting fee for each campaign registration**. This applies to all campaign types (Standard, Low-Volume, etc.) and is charged when you submit the campaign for review. It’s essentially the fee for carriers/TCR to review and assign a Campaign ID and (if applicable) a Trust Score for the campaign/brand combination. **Important:** This $15 is non-refundable, even if your campaign is rejected. Twilio will charge again if you register a new campaign (or sometimes if you make major edits that require re-registration). However, Twilio does _not_ charge additional if minor resubmissions are needed to fix details on the same campaign record. - _Exception:_ Twilio at one point waived the $50 campaign setup fee for Low Volume campaigns: that was the old fee structure. The current $15 vetting fee is generally standard for all now. - **Monthly Campaign Fees:** Once approved, each campaign incurs a **monthly fee** (billed by Twilio, passed to carriers). This is charged per campaign (not per number) and varies by campaign type: - **Standard Campaigns:** $10 per month. This covers any standard use case (marketing, 2FA, notifications, etc.) as well as Mixed campaigns (if not using the low-volume version). So if you have a regular campaign on a Standard Brand, expect $10/mo campaign fee. - **Sole Proprietor Campaign (Starter):** $2 per month. Sole Prop brands can only have the one starter campaign, and it’s charged at $2 monthly. - **Low-Volume Mixed Campaign:** $1.50 per month. This discounted rate applies only if you chose the “Low-Volume Mixed” use case (usually tied to Low Volume Brand). It’s the cheapest option. - **Charity (501c3) Campaign:** $3 per month. Significantly reduced to encourage nonprofit messaging. - **Emergency Services Campaign:** $5 per month. - **Political Campaign:** Twilio doesn’t list a special price in the source, so it’s likely $10/mo (same as standard). Some other carriers waive per-message fees for political, but monthly is typically standard. - **Others:** K-12 Education and Sweepstakes etc. often carry $10/mo (like standard). “Proxy” conversational campaigns are $10/mo as well. These fees are **per active campaign**. So if you have two $10 campaigns, you pay $20/mo. Twilio usually pro-rates them if you start mid-month. They appear on your Twilio bill as A2P Campaign fees. - **Per-Message Carrier Fees:** U.S. carriers impose small surcharges on A2P messages in addition to Twilio’s messaging price. The fees differ by carrier and whether traffic is registered or not: - **AT&T:** for _registered A2P_ messages, $0.002 per SMS and $0.0035 per MMS. For _unregistered_ traffic (10DLC without a campaign) it is **$0.01 per SMS and $0.015 per MMS**, a much steeper rate after AT&T raised unregistered fees on June 1, 2023. These are **outbound** fees (AT&T’s surcharge applies only to messages sent to AT&T subscribers). - **T-Mobile (including Sprint):** charges apply **for both inbound and outbound** messages on T-Mobile’s network. _Registered:_ $0.003 per SMS and $0.01 per MMS. _Unregistered:_ currently $0.006 per SMS and $0.015 per MMS (T-Mobile increased these on Aug 1, 2023). So an outbound text to a T-Mobile phone, if your number is registered, adds $0.003; if not, $0.006. If that T-Mobile user replies (inbound to you), Twilio passes through another $0.003 or $0.006 respectively. - **Verizon:** $0.0025 per SMS and $0.005 per MMS **for any A2P message**, registered or not. Verizon doesn’t currently penalize unregistered traffic with higher fees (they instead heavily filter or block unregistered, but fee is same). - **US Cellular and others:** smaller carriers have various tiny surcharges (often around $0.0025~0.003 per message). The big three above are the main ones. Twilio will automatically add these fees on a per-segment basis. For example, sending a 2-segment SMS to an AT&T phone will incur $0.004 in AT&T fees (2 × $0.002) if registered, or $0.02 if unregistered. These appear in your billing as “Carrier Fees”. Keep in mind MMS often count as multiple segments or have per-message fees as shown. > **Important:** If you send 10DLC messages _without registering_, you pay those elevated “unregistered” fees (and risk blocking). For instance, each unregistered SMS to a T-Mobile user costs an extra $0.006, which adds up quickly. So it’s very cost-effective to register and pay the low registered rates. The difference can be **5 to 10× higher cost** per message when unregistered (e.g. AT&T $0.01 vs $0.002). Aside from the above, Twilio’s standard per-message charge for the SMS/MMS itself still applies (e.g. ~$0.0075 per SMS segment to US). Toll-free and short code messages do not incur these 10DLC fees but come with their own rules. For completeness, Twilio also notes **additional fines/penalties** can be charged by carriers for non-compliance (more on that in compliance section). The table below summarizes the key costs for each Brand/Campaign type. ## Throughput and Volume Limits (by Brand/Campaign Type and Vetting Score) One of the main advantages of A2P 10DLC registration is access to higher **throughput** (messages per second) and higher daily send limits, but these limits vary widely by brand type and your **Trust Score**. Here’s a breakdown: - **Sole Proprietor Brand Limits:** This is the most restricted tier. A Sole Prop (Starter) campaign is limited to **1 MPS (message per second)** sending rate. In addition, there is a daily cap of **3,000 SMS segments/day** across all carriers, with a sub-limit of **1,000 SMS/day to T-Mobile** numbers. These limits are imposed by carriers to ensure small senders don’t spam. In practical terms, 3,000 messages/day (~90k/month) is the absolute max for Sole Prop, and if more than 1k of those go to T-Mobile, the rest may be blocked until the next day. This tier has no formal Trust Score, just fixed capacity. - **Low Volume Standard Brand Limits:** By design, Low Volume campaigns are capped at the **“lowest throughput tier.”** Currently, that means about **3.75 MPS** combined across the major carriers, which is the figure Twilio publishes for this tier. Daily volume is capped around **6,000 SMS/day**, with a sub-cap of **2,000 SMS/day to T-Mobile**. These numbers are roughly double the Sole Prop, but far below what a fully vetted standard brand can achieve. Notably, **Trust Score does not apply**, because even though you have an EIN, Twilio does not do secondary vetting for low volume, so your throughput is essentially fixed at this low tier. This is sufficient for many small businesses (a few thousand texts/day). If you exceed these, you should upgrade to a Standard brand. - **Standard Brand Limits (with Trust Scores):** Standard brands can achieve **very high throughput**, but it depends on the Trust Score assigned during brand vetting. - If your Trust Score is **75 to 100 (High)**, you get the maximum throughput, **up to ~225 MPS** across major carriers. In practice, that equates to 75 MPS on AT&T, 75 MPS on Verizon, 75 MPS on T-Mobile simultaneously (the sum ~225). Daily volume allowed can be ~**200,000 messages/day** or higher (and carriers can lift it further on request for very large senders). This tier is usually granted to large, well-established brands (e.g. Fortune 500 companies). - At Trust Score **50 to 74 (Medium)**, throughput is about **120 MPS total**, which breaks down to ~40 MPS per major carrier. Daily cap maybe around 50k to 100k/day. - Trust Score **1 to 49 (Low):** throughput around **12 MPS total** (approx 4 MPS per carrier). Daily sending might be limited to ~2,000 to 10,000/day (Twilio mentions 2,000 for lowest tier in older docs). - If a Standard Brand had **0 Trust Score** (meaning you skipped vetting: not typical now since $15 vetting is mandatory for new campaigns), it would be treated equivalently to the 1 to 49 tier, ~12 MPS. _Note:_ These throughput numbers refer to SMS segments per second outbound. “Major networks” usually means AT&T, Verizon, T-Mobile (each considered a channel). “Minor” carriers (others) are typically collectively given a smaller portion (like 1 MPS each or a combined minor bucket). The table from Twilio (via CallHub) shows the per-carrier breakdown clearly. Essentially, a high trust brand can send extremely fast, whereas a low trust brand might only send 12 SMS/sec (which is ~720 SMS/minute). If you need more throughput and got a low score, Twilio offers a **Secondary Vetting** process (for a fee) that can potentially bump your score into a higher tier if justified. Also, **daily limits** still exist at each tier (to prevent massive spam dumps). Twilio indicates 200k/day at high score, down to 2k/day at low score. T-Mobile’s daily cap for a low-tier campaign is 2k (matching the low score case), which aligns with those numbers. High-score campaigns get 200k/day or more (T-Mobile can approve higher via Special Business Reviews if needed). - **Special Use Case Throughput:** Many special campaigns are **not governed by trust score at all**, but by their nature: - _Charity:_ Often given a fixed high throughput (e.g. treated as high trust by default). There is indication that Trust Score doesn’t limit special cases. So a small nonprofit might still get good throughput for urgent sends, though in practice nonprofits rarely need huge MPS. - _Emergency:_ Typically very high throughput (carriers will allow blasting out alerts rapidly, as these are life-safety). - _Political:_ If verified, these campaigns have predefined high throughput buckets (for example, registered 527 political campaigns often get tens of MPS or more by default, since election texts are high volume). - _Low-Volume Mixed:_ As noted, fixed at about 3.75 MPS combined, regardless of the Brand’s potential trust. To summarize: standard brands with high trust can send at **short-code-like speeds**, hundreds of messages a second, whereas sole prop and low volume are throttled to a crawl, 1 to 3.75 MPS. Thus, choose your brand type and consider vetting if you need more speed. Also design your campaigns accordingly: e.g. if you plan a big blast to 50k users in 5 minutes, you will need a high throughput campaign (High trust or short code); a Low Volume campaign would take hours to send that many messages (and may violate daily caps). Twilio’s system will queue messages beyond your throughput rate automatically. It’s good practice to spread out large sends if you are on a limited tier to avoid hitting caps. Twilio provides a **“Market Throughput”** API/guide to show expected MPS for your campaign after approval. ## Compliance and Policy Requirements (Opt-In/Out, Content, Violations) A2P 10DLC comes with strict **compliance rules**. When you register and use a campaign, you **must adhere to carrier and industry (CTIA) guidelines** or risk message blocking and hefty fines. Key requirements: - **Consent (Opt-In):** **You must only message users who have given proper consent**. This is the golden rule of A2P. Acceptable opt-in can be the user providing their number for a specific purpose (e.g. signing up on your website and agreeing to receive texts). _Implicit_ consent (like an existing business relationship) is not sufficient for marketing texts: you need explicit opt-in for promotional campaigns. During campaign registration Twilio asks how users opted in; make sure you have a solid process (web form checkbox, SMS keyword, etc.) and **retain proof of opt-in** in case of carrier audit. - **Opt-Out (STOP) and Help:** You **must support the standard keywords** for opt-out and help. If a user texts “STOP”, “STOPALL”, “UNSUBSCRIBE”, “CANCEL”, “END” or “QUIT” to your number, you **must immediately stop sending messages** to that user. Twilio can manage this automatically if you enable the Messaging Service’s Opt-Out feature. Similarly, if a user texts “HELP”, you should reply with a message describing how they can get assistance or unsubscribe (e.g. “Reply STOP to unsubscribe, or contact us at 1-800-XXX for help”). Carriers monitor that these keywords function correctly. Not honoring STOP is one of the most serious violations and can lead to your campaign being shut down or fines (T-Mobile has a known $10,000 fine for not honoring opt-out). - **Forbidden Content:** Carriers and CTIA forbid certain types of content on A2P messages. A common acronym is **SHAFT** (Sex, Hate, Alcohol, Firearms, Tobacco), the categories that are generally disallowed in marketing messages. This means no pornography, no hate speech or harassment, no promotion of illegal drugs, etc. Other forbidden content includes: - **Illegal substances or activities:** e.g. cannabis (still federally illegal, so carriers ban cannabis dispensary texts even in legal states), gambling (unless legal and very targeted), fake pharmaceuticals, etc. - **Harassment or hate:** any content that harasses or discriminates is banned. - **Spam behavior:** Scams, phishing, or fraudulent content is obviously forbidden. - **“S.H.A.F.T.” for marketing:** Even for legal products like alcohol or firearms, carriers do not allow marketing texts unless very tightly controlled (age-gated lists, etc.). It’s safest to avoid those content categories entirely in texting. - **Note:** Charities can send certain fundraising messages, and some regulated content (like prescription reminders from pharmacies) may be allowed if the user explicitly opts in and laws permit. Always check Twilio’s Use Policy and the CTIA guidelines if in doubt. - **Content Compliance:** Every message should ideally include identifying info (e.g. your company name in the message) especially for marketing. Avoid link shortening with public link shorteners (bit.ly etc. are red flags for carriers; use your own domain or Twilio’s branded domain if using their shorter). Do not send on forbidden hours if any apply (for instance, some states have telemarketing curfews: text is generally exempt, but best practice is avoid very late marketing texts). - **Frequency and Volume:** Don’t suddenly blast at your max throughput without warning. Carriers watch for unusual spikes. If you plan a large campaign, you might want to notify Twilio support in advance. Also, ensure your sending volume aligns with what you stated in campaign registration: e.g. if you said “we send 100 messages/day” and then send 50k in an hour, that mismatch can trigger filtering. **Consequences of Violations:** If you violate these rules, carriers may: - **Filter or block messages:** The first sign of non-compliance is usually messages failing to deliver. Carriers have automated filters that will block texts that look like spam or forbidden content. - **Issue fines:** For egregious or repeated violations, carriers (especially T-Mobile) can charge substantial fines **passed through to you via Twilio**. For example, T-Mobile’s spam violation fine is **$10,000** per instance. They also fine for “snowshoeing” (spreading traffic across many numbers to evade limits), for example a $1,000 fee for evasion attempts. AT&T and others have similar penalties. These fines are not theoretical, since companies have been hit with them, and Twilio will bill you if it happens. - **Campaign suspension or ban:** Carriers can revoke your campaign registration. If your campaign gets suspended or permanently deactivated due to compliance issues, you’ll be barred from using those numbers for texting until you remedy it (if possible). Twilio may also suspend your messaging capabilities if you get flagged for serious violations. In short, **play by the rules**: - Make sure everyone you text has opted in and is expecting your messages. - Always include an easy opt-out. (Twilio’s automated STOP handling can insert “Reply STOP to unsubscribe” for you: use it.) - Don’t send disallowed content. When in doubt, consult Twilio’s AUP or contact their compliance team. - Keep your campaign description truthful. If you registered for “account alerts” and start sending marketing, that’s a violation. Following these guidelines not only avoids penalties but also **improves your deliverability**. Carriers want to deliver wanted messages; if you follow best practices, your messages are far more likely to reach recipients. ## Comparison Tables Below are two tables comparing **(1) Brand types** and **(2) Campaign use case types** for A2P 10DLC on Twilio, summarizing their characteristics, costs, limits, and pros/cons. ### Brand Types Comparison | **Brand Type** | **Who Can Use (Eligibility)** | **Requirements / Docs** | **One-Time Registration Fee** | **Messaging Throughput & Limits** | **Pros** | **Cons** | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sole Proprietor**
(Starter Brand) | Individuals or very small businesses **without an EIN**.
Must have US or Canada identity/address. Only supports 1 phone number. | Basic personal info (name, address).
No EIN needed. Identity verification is light (Twilio may ask last-4 SSN or ID in some cases). | ~$4 one-time. | **Throughput:** 1 MPS (very low).
**Daily cap:** ~3,000 msgs/day (1000 to T-Mobile max).
**Campaign limit:** 1 campaign (Starter) on 1 number. | - Easiest to register (no business docs needed).
- Lowest cost upfront.
- Ideal for hobby projects or sole proprietors with minimal texting needs. | - Very limited volume (slow sending and daily max).
- Can only use one number (no number pooling for load).
- Trust Score not applicable, always low tier throughput.
- Not suitable for growth: requires upgrade to add numbers or send more. | | **Low-Volume Standard**
(“Starter/EIN” Brand) | Small businesses **with an EIN** (or non-US business reg) that have low messaging needs.
Up to 400 phone numbers can be used, but meant for low traffic. | Standard business info in Trust Hub, meaning EIN or foreign business number, legal name, address, etc.
No extra vetting documents beyond proof of business if requested (usually none). | ~$4 one-time (similarly low fee; Twilio lists $4.41 in some docs). | **Throughput:** ~3.75 MPS combined (low fixed tier).
**Daily cap:** ~6,000 msgs/day (2000 to T-Mobile).
**Campaigns:** Up to 5 campaigns allowed per brand (if needed), though all share the low volume cap. | - Low cost registration and no secondary vetting needed (quick approval).
- Allows use of many phone numbers (up to 400), which is good for broad geographic presence or number rotation, as long as volume per campaign is low.
- Covers most small business needs at minimal monthly fees (when combined with Low-Volume campaign type). | - Throughput locked at **lowest tier** regardless of company size or reputation (Trust Score not used).
- Not suitable for moderate or high volume, since hitting 6k messages/day or 3.75 MPS will cause queuing or blocking.
- If business grows, requires upgrading to Standard Brand (involves additional vetting and fees). | | **Standard**
(Full Business Brand) | Any business or organization with **EIN or international equivalent**. Required for higher volumes or multiple use cases. Supports 400 numbers and 5 campaigns per brand. | Detailed business info in Trust Hub (EIN, address, etc).
Must pass the vetting algorithm, where consistency of data is key. Special designations may require proof (e.g. non-profit IRS letter, Campaign Verify token for political). | $44 one-time (higher due to vetting costs). | **Throughput:** **Varies by Trust Score:**
- High score (75 to 100): ~225 MPS (75/sec to each major carrier).
- Medium (50 to 74): ~120 MPS total.
- Low (1 to 49): ~12 MPS total.
**Daily caps:** ~200k/day at high trust, down to ~2k/day at lowest.
**Campaigns:** 5 allowed, each can utilize throughput if brand score is high (throughput is per brand+campaign). | - Enables **high volume messaging** with strong throughput, suitable for large campaigns and time-sensitive sends.
- Flexible: can run multiple campaigns (e.g. separate marketing vs. transactional) under one brand.
- **Trust Score** rewards reputable businesses with very high limits, leaving room to scale.
- Only option for special use cases (Political, Charity, etc.) which come with perks (lower fees, high throughput for those cases). | - Higher upfront cost and more paperwork (EIN required, must ensure business info is accurate).
- Vetting outcome (Trust Score) can be unpredictable; a low score will throttle you (possibly requiring additional vetting fee to improve).
- Slightly longer setup time (though still usually same-day approval, some cases up to a day or two if manual review).
- Must maintain good sending practices to keep campaign in good standing (brand can be flagged if campaigns under it violate rules). | ### Campaign Types/Use Cases Comparison | **Campaign Type / Use Case** | **Allowed Content & Typical Use** | **Monthly Fee** | **Throughput & Limits** | **Pros** | **Cons / Considerations** | | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sole Prop Campaign**
(Starter Campaign) | Any type of messages by a sole proprietor (since only one campaign allowed, it can be mixed content). Often used for appointment reminders, small marketing pushes, etc., under one umbrella. Must comply with all opt-in rules: typically low-scale, local messaging. | $2/month | Fixed low throughput: **1 MPS, ~3k messages/day** (1k/day to T-Mobile). Only one phone number can send for this campaign. | - **Very low cost** to maintain.
- Simplest setup (one campaign covers all needs for an individual sender).
- No distinction between use cases needed: flexible content (within compliance) as long as volume is low. | - Cannot increase volume without migrating to a different brand type.
- If you start sending higher volume, you’ll hit throttling or carrier filtering quickly.
- Mixed content might be less optimized (e.g. marketing vs. transactional not separated).
- Still subject to all compliance rules (must handle STOP, etc.). | | **Standard Campaign (Declared use case)**
_e.g._ Marketing, Account Notifications, 2FA, Customer Care, etc. | A specific, singular use case chosen from the standard list (except marketing is considered declared too, for this purpose). Content must align with that purpose. **Examples:**
- _Marketing_: sales promos, coupons, product offers.
- _2FA_: one-time passwords, login codes.
- _Notifications_: account or delivery updates.
- _Customer Care_: service chats initiated by user.
_(See table above for more.)_ You should **stick to the declared content**: carriers expect the traffic to match the category. | $10/month | **Throughput:** Depends on Brand’s Trust Score tier (High/Med/Low as above). A higher trust will allow very fast sending for these campaigns. Declared (non-marketing) use cases typically were allowed the same throughput as marketing in the latest guidelines, but initially they were viewed as slightly lower-risk.
**Daily:** Up to brand’s cap (e.g. high trust 200k/day). | - By focusing on one use case, you **minimize content ambiguity**, which can improve deliverability (carriers can better predict your traffic pattern).
- Required for certain sensitive use cases (e.g. fraud alerts), because it ensures compliance elements specific to that case are covered.
- If non-marketing, possibly less scrutiny from carriers (marketing messages often face heavier filtering). | - $10/month fee for each campaign, so if you need multiple use cases, costs add up.
- If you mislabel (choose wrong use case), campaign can be rejected or, if approved, later flagged for non-compliant content.
- Running separate campaigns means you need to manage multiple Messaging Services/numbers or dedicate numbers per campaign (logistical overhead). | | **Standard Mixed Campaign**
(Mixed use case, high-volume) | A combination of multiple use cases in one campaign. Allows **marketing + transactional** together or any mix of at least two purposes. E.g. you might send appointment reminders _and_ promo offers from the same number. Content can span those categories. Good for brands who want one pool of numbers for all messaging. | $10/month (same as standard) | **Throughput:** Also tied to Trust Score (so potentially high). However, note that all your traffic (marketing + others) shares the same throughput bucket of that one campaign.
**Daily:** Up to brand cap (e.g. 200k/day if high trust), but all message types combined. | - Convenience of one campaign/number set for all messages (no need to separate phone numbers by use case).
- Only one monthly fee instead of multiple campaigns (if you were to separate).
- Particularly useful if volumes are not high enough to justify separate campaigns for each category. | - **Higher risk of filtering**: Mixed campaigns, especially those including marketing, might see more scrutiny. Any spam complaints or high opt-out rates affect the whole campaign’s traffic (could even impact transactional messages if carriers throttle the campaign).
- You still must list the sub-use-cases when registering. Carriers expect at least 2 use-case types to be declared for “Mixed”. All those must individually meet compliance (opt-ins for each message type).
- **No throughput benefit**: If you have high volume in both marketing and alerts, a single mixed campaign might become a bottleneck. Separate campaigns would each get their own throughput allocation (effectively doubling total throughput if you have separate streams). | | **Low-Volume Mixed Campaign** | A special type of Mixed campaign meant for **small volume** senders. Can include multiple message types (like a standard mixed), but it’s understood volume will be low. Typically used with Low Volume Brands. Content: any combination (promo, notifications, etc.) but on a small scale. | $1.50/month (cheapest) | **Throughput:** **Fixed at ~3.75 MPS combined** regardless of Trust Score, so even a large company that picked this by mistake cannot exceed it.
**Daily cap:** ~6k/day (with ~2k/day on T-Mobile), essentially the brand’s low limit. | - _Ultra cost-effective:_ only $1.50/mo plus the one-time $15 vetting fee, which is great for small businesses on a budget.
- **Covers all use cases** a small business might have in one campaign (simple management).
- No need to worry about trust score or vetting, since it’s auto-approved at low throughput with minimal fuss. | - Very low throughput; not suitable beyond a few thousand messages daily or bursts faster than ~3.75/sec.
- If you exceed the low limits, carriers will queue or drop messages. There’s no way to get more throughput on this campaign without migrating to standard (which means new campaign, $15 fee, $10/mo, etc.).
- If a business underestimates their needs and picks Low-Volume Mixed, they might later have to transition campaigns, which can be a bit of work (and possibly downtime to re-register). | | **Charity Campaign**
(501c3 Nonprofit) | **Content restricted to the organization’s charitable/non-profit purposes.** Examples: fundraising appeals, program updates to donors, event notifications for a charity. The campaign should not be used for commercial/promotional content (no profit motive). Only available to verified 501(c)(3) orgs. | $3/month (very low) | **Throughput:** Treated kindly by carriers: typically high throughput (often equivalent to high trust standard). Also, **Trust Score does not limit it** (special class). Many carriers waive or minimize message fees for these (e.g. T-Mobile and AT&T waive per-message surcharges for approved charity campaigns). | - **Lowest messaging costs**: discounted monthly fee and $0 carrier fees make messaging cheap (you only pay Twilio’s base rate).
- Likely to face less filtering since non-profits are given benefit of doubt (assuming messaging is indeed non-commercial).
- Can reach large audience for campaigns like Giving Tuesday blasts without huge fees. | - Only for bona fide charities: requires proof (which can slow registration if documentation isn’t handy).
- If any content strays into pseudo-commercial (e.g. a non-profit hospital doing cosmetic service promos), carriers could revoke charity status: you must stick to true charitable communication.
- Still need opt-ins like any other campaign (don’t assume being a charity allows messaging people without consent!). | | **Emergency Services** | Government or emergency organizations sending **urgent alerts** to the public. Content must be emergency in nature (imminent threat to safety, or public service announcements in emergencies). E.g. evacuation notices, AMBER alerts, disaster response updates. Not for general govt info: truly emergency only. | $5/month | **Throughput:** Extremely high (carriers allow such traffic to be sent quickly due to urgency). Often not trust-score-limited. However, use of this type is rare and closely monitored. | - Prioritized delivery in critical situations (this is the _only_ scenario where carriers might bypass usual filters due to the importance of the content).
- Low cost for what it offers (imagine sending millions of alerts at $5/mo plus message costs).
- Essential for agencies that qualify; can save lives with timely comms. | - Rigidly restricted to true emergencies: misuse will be taken very seriously. Likely requires coordination with Twilio (or special onboarding) to get approved.
- You may need to demonstrate you are a government entity or part of emergency infrastructure. Not open to businesses at all (a company cannot declare an “emergency campaign”). | | **Political Campaign**
(2024 Election, etc.) | Partisan political messaging to voters, by registered political organizations (PACs, campaigns, parties). Content: campaign announcements, get-out-the-vote (GOTV) texts, fundraising for candidates, issue advocacy by 527 orgs, etc. Must not be spammy: still needs opt-in (e.g. subscribers or constituents lists). Requires “Campaign Verify” vetting token prior to registration. | ~$10/month (standard): no special Twilio fee break, though carriers don’t surcharge these messages beyond normal rates. | **Throughput:** Very high: carriers assign special throughput tiers for verified political traffic (to allow mass texting during campaigns). Typically equal to or greater than high-trust commercial campaigns. E.g. tens to hundreds of MPS are possible. Also no daily cap as long as opt-ins are respected (but extremely large sends might require carrier coordination). | - Enables large-scale voter contact via SMS: crucial for modern political campaigns.
- Carriers generally permit such traffic despite volume, because senders are verified and there are legal protections for political speech (in the U.S.).
- No extra per-message fees beyond standard (unlike spam, which could be fined). | - **Not available to normal businesses.**
- Has an external verification step ($95 fee and some time to process) which must be done first: adds complexity.
- Still must follow opt-out/opt-in rules strictly. Political senders have been fined for texting voters without consent; being a political org is not immunity from TCPA. Also, content that verges into hate speech or disinformation can still get filtered or lead carriers to shut down the campaign. | _(Table notes: “MPS” = messages per second throughput. Trust Score impacts apply only to Standard campaigns. Fees cited are Twilio’s charges per month per campaign. Content rules are simplified summaries; always ensure compliance with detailed guidelines.)_ ## Best Practices and Recommendations Finally, to get the most out of Twilio A2P 10DLC while keeping costs down and deliverability up, consider these best practices: - **Choose the Right Brand Type:** If you’re a low-volume sender, don’t overpay for a Standard Brand. For example, an individual or small startup sending a few hundred texts a day should go with Sole Prop or Low Volume Standard, where the fees are minimal and registration is easier. On the other hand, if you anticipate scaling or need multiple campaigns, invest in a Standard Brand from the start. _Tip:_ If you have an EIN, you might opt for Low Volume first (cheap) and Twilio allows later upgrading to Standard Brand if needed. Upgrading will involve the higher fee but can be done when justified. - **Choose Campaign Use Case Wisely:** **Match the campaign type to your primary content.** If most of your messages are one type (say, purely notifications), register that specific use case, and don’t pick “Mixed” just to cover a rare promo, you could instead send that promo on a separate smaller campaign or even via a different channel. Specific use cases mean carriers know what to expect, which can help with deliverability. If you truly have multiple equally important use cases on one number and volume is low, the Low-Volume Mixed is a perfect catch-all. If volume is higher, you might still consolidate under a Mixed campaign but be aware of the trade-offs. Twilio’s guidance is if you _can_ separate use cases into distinct campaigns, do so for clarity, especially separating marketing from critical alerts. - **Use Separate Campaigns for Marketing vs. Transactional:** It’s often considered **best practice to separate promotional traffic from transactional traffic**. Reason: transactional (alerts, 2FA, receipts) typically have high deliverability requirements and users expect them, whereas marketing content has higher opt-out rates and filtering risk. If they are on the same campaign/number and a carrier filters some of your marketing messages for spam suspicion, your important messages might also be affected (since the campaign could get a bad “reputation” temporarily). By using say one campaign/number for marketing and another for notifications, you isolate risk. Many businesses use one long code for service messages and a different long code (or set of numbers) for marketing blasts. - **Minimize Costs:** - **Low Volume option:** As mentioned, if your needs are modest, use the Low Volume Brand + Low-Volume Mixed campaign, which costs $19.50 one-time ($4.50 for the brand plus the $15 campaign vetting fee) and $1.50/mo, which is incredibly cheap compliance. Compare that to not registering: you’d pay extra per message fees that likely exceed $1.50 within a few hundred texts. So registration almost always saves money unless you truly send only a handful of messages ever. - **Avoid unnecessary campaigns:** Each campaign is $10/mo (except special ones). If one campaign can cover two related use cases without issue, consolidate them. For example, if you send account notifications and also occasional security alerts, those are similar enough you could register a single “Account Notifications” campaign for both rather than two separate $10 campaigns, as long as the content fits the description. - **Use special categories if eligible:** If you’re a nonprofit, definitely register your campaign as _Charity_, which saves $7/month in fees and $0.002 to 0.003 per message in surcharges. If you’re a political org, use the Political route (yes, it’s some extra work up front, but then you avoid carrier shutdown during election season and your throughput will be sufficient). - **Keep your number pool minimal:** You pay $1 per month for each Twilio phone number. For most A2P campaigns, you only need as many numbers as required for throughput or regional presence. Don’t register dozens of numbers “just because”, since you’ll incur extra monthly costs (and carriers dislike unused numbers parked on campaigns). Start with 1 or a few and add as needed. - **Toll-Free for very low volume?** If your SMS volume is extremely low and you want to avoid all A2P registration, a workaround is to use a **Toll-Free number**, which currently has a separate (free) verification process and no per-message surcharges. Toll-free texting can handle decent throughput for transactional messages (and now even limited marketing). However, note toll-free has its own rules and carriers are enforcing verification for it too. If you already have Twilio setup, sticking to 10DLC is usually fine; but for a hobby project sending a handful of texts, toll-free might save that $19.50 in registration fees. (As a side note, Twilio itself in docs suggests toll-free or even using their Verify API for solely auth messages if you truly want to skip 10DLC.) - **Maintain Compliance Proactively:** The best way to ensure good delivery and avoid costs is to never trigger carrier red flags: - Make sure **opt-out language** is present at least in initial messages. For marketing in particular, best practice (and in some cases required by CTIA) is to include “Txt STOP to stop” in the first message to a user. Twilio’s compliance tools can automate this in Messaging Service. - Monitor your **opt-out (STOP) rates** and **complaint rates**. Twilio provides feedback via delivery receipts (some carriers report if a user replied STOP). If you see high opt-outs after a send, evaluate your contact list and content, since it could indicate those users didn’t expect that message. - **Quality over quantity.** Do not spam blast messages or send repeatedly to non-responders. Carriers watch patterns like a high volume of identical messages or rapid-fire deliveries, and that can cause filtering. Space out large sends when possible, and personalize content if you can (non-identical messages are less likely to get auto-filtered). - **Content checks:** Avoid ALL CAPS, spammy phrases (“FREE!!!”), excessive link use in marketing texts. These can trip filters. Also, avoid public URL shorteners (they are often blocked). Use your own domain or Twilio’s shortener with a branded domain. - **Update campaign details if things change:** If you pivot your use case significantly, officially you should register a new campaign for the new use. Don’t keep using an old campaign for a totally different messaging purpose, because that could lead to content that doesn’t match what carriers expect, and result in filtering or suspension. - **Leverage Messaging Service Features:** Twilio Messaging Services offer features like **Sticky Sender** (keeping the same number per contact) and **Scalability with multiple senders**. If you have multiple numbers in a campaign, Twilio can distribute traffic (Scaler) to maximize throughput and avoid hitting per-number carrier limits. Ensure these are configured to optimize delivery. Also enable **Smart Encoding** and consider using **Twilio’s Queueing** with a proper logic if you know you might exceed throughput, since Twilio will queue messages above your MPS, but you might manage scheduling on your side too. - **Monitor and Test:** Use Twilio’s **Messaging Insights** and delivery reports to keep an eye on how your messages are doing. If you see sudden increases in failures or latencies, it might be a compliance issue (e.g. carriers started filtering). Address issues quickly, because sometimes a tweak in messaging or a direct talk with Twilio support to understand a block can resolve it. **Staying in good standing is key**; once a campaign or number gets a bad reputation, it’s hard to reverse during its active period. By following these practices, you’ll ensure your Twilio A2P 10DLC experience is smooth, with **optimized costs, maximum deliverability, and compliance**. The A2P system may seem complex, but it ultimately works in your favor by boosting trust in the messages you send, which means more of your customers actually receive and engage with your texts. Good luck with your messaging campaigns! **Sources:** The information above is based on Twilio’s official documentation and help center articles, Twilio’s stated pricing and fee schedules, and industry guidelines for A2P messaging compliance. All data is up-to-date as of 2025 for US A2P 10DLC regulations and Twilio’s services. --- ### The Real Bottleneck in Software Development Isn’t Writing Code - Type: Article - Canonical URL: https://www.piyushgambhir.com/blogs/the-real-bottleneck-in-software-development-isnt-writing-code - Summary: Typing was never the slow part. Where the time actually goes in shipping software, and why writing code faster moves the needle less than you would hope. - Published: 2025-08-10T00:00:00.000Z - Topics: LLMs, Software Development, Productivity _Inspired by [ordep.dev’s post: “Writing Code Was Never the Bottleneck”](https://ordep.dev/posts/writing-code-was-never-the-bottleneck) and [Theo’s talk on SDLC and iteration speed](https://www.youtube.com/watch?v=i44jQvcDARo&t=453s&ab_channel=Theo-t3%E2%80%A4gg)_ For as long as I’ve been building software, one thing has been clear: the slowest part of the process has never been typing out the code. The real delays happen in everything that surrounds it: understanding the problem, aligning on a solution, reviewing, testing, debugging, and keeping the system healthy over time. The software development lifecycle (SDLC) has always been more about **coordination, clarity, and correctness** than raw typing speed. Now, with large language models (LLMs) able to generate working code in seconds, it’s tempting to think we’ve removed the bottleneck. But in reality, we’ve only moved it. ## The Bottlenecks Before LLMs In a traditional SDLC, the slowdowns are predictable: - **Code reviews** take time because reviewers need to understand the intent, not just the syntax. - **Knowledge transfer** through mentoring, pairing, and documentation is slow but necessary. - **Testing and debugging** require careful thought and iteration. - **Coordination overhead** (tickets, planning meetings, and agile rituals) eats into actual build time. These steps exist to ensure quality, but they also slow down delivery. They require shared understanding, not just working code. ## What LLMs Actually Change LLMs like GPT, Claude, and others can produce functional code quickly. They can scaffold features, write boilerplate, and even suggest optimizations. This changes one thing: the **time to first draft** drops dramatically. But the rest of the process remains: - The code still needs to be **understood** by humans. - It still needs to be **reviewed** for correctness, security, and maintainability. - It still needs to be **integrated** into existing systems without breaking them. - It still needs to be **tested** against real-world scenarios and edge cases. In fact, these steps can become harder when the code is generated by a model: 1. It’s unclear whether the author fully understands what they submitted. 2. The generated code may introduce unfamiliar patterns or break established conventions. 3. Edge cases and unintended side effects may not be obvious. The result: more code flows into the system faster, but the verification and integration stages become the new choke points. ## The Cost of Understanding The biggest cost in software is understanding it, not writing it. Every line of code is a liability until it’s proven correct, maintainable, and aligned with the system’s goals. LLMs don’t reduce the mental effort required to reason about behavior, identify subtle bugs, or ensure long-term maintainability. If anything, the cost of understanding can increase when reviewers have to reverse-engineer the reasoning behind generated code. ## The Risk of Skipping Shared Context Software engineering is collaborative. It depends on shared understanding, alignment, and trust. When code is generated faster than it can be discussed or reviewed, teams risk assuming quality instead of ensuring it. This creates silent technical debt: code that works now but becomes a maintenance problem later. ## Where LLMs Actually Help The real value of LLMs is in **prototyping and iteration**: - Quickly testing an idea before committing to a full build. - Creating throwaway code to explore solutions. - Automating repetitive or boilerplate-heavy tasks. - Reducing the time to the next realization: the moment you learn something new about the problem or solution. In these contexts, speed matters more than polish, and the cost of mistakes is low. ## How the SDLC is Evolving The traditional SDLC often looks like this: 1. Research 2. Design 3. Spec 4. Build 5. Ship 6. Pray 7. Deprecate because it didn’t work A more adaptive, LLM-augmented process could look like this: 1. Identify the problem 2. Prototype quickly (using LLMs if useful) 3. Collect feedback 4. Iterate until the idea is solid 5. Write a minimal spec for how to build it right 6. Build the production version 7. Beta test 8. Collect feedback and refine The key difference is **front-loading learning** and **reducing the time to the next realization**. Instead of spending months on specs and planning before writing a line of code, teams can validate ideas in days or weeks, then invest in productionizing only what’s proven valuable. ## The Hard Truth LLMs don’t remove the need for clear thinking, careful review, and thoughtful design. They don’t eliminate the human work of aligning on goals, understanding trade-offs, and maintaining systems over time. Yes, the cost of writing code has dropped. But the cost of making sense of it together as a team hasn’t. That’s still the bottleneck. If we want faster, better software, we need to focus less on typing speed and more on **shortening feedback loops, improving shared understanding, and making the review and integration process more efficient**. LLMs can help with that, but only if we use them to accelerate learning rather than to produce more code.