← Back to Blog

Build Your Own AI Prompt Hub: One Link to Claude Code, ChatGPT and Your Local Models, With Phone Alerts

· 15 min read A small glowing switchboard box on a dark desk with three thin amber lines running into it and three soft blocks of light rising from it, a plain smartphone lying beside it.

By October our server had more AI than we could keep track of. Claude Code and Codex were signed in to two paid subscriptions. Two local models ran in Ollama behind LiteLLM. Qdrant held a searchable copy of our notes, n8n ran the automations, and there was a Linux desktop in a browser plus a Windows 11 virtual machine. Using any of it meant remembering which terminal to open, which machine to SSH into, and whether Claude had hit its usage limit an hour ago.

So we built one front door. We call it llmhub. It is a small web page, a terminal command and a desktop icon, and they all do the same thing: take a prompt, decide where it should run, show you that plan, and run it only when you say so. Claude Code gets the work first. If Claude reports a usage limit, ChatGPT takes over through the Codex command line. Local models do the cheap triage and step in only when both are out. When a job finishes, the phone buzzes.

The first real job was a one-line question answered by Claude Code on the server. The command line reported its equivalent API cost as $0.11, which is about what you'd expect for a short answer. Everything below is what it took to get there, and what to copy if you want your own.

Downloads: the Prompt Hub Kit has the architecture, the security checks and the core code. The ntfy Phone Alerts Cookbook has the phone setup and a dozen small-business alert recipes you can paste into a script today.

What a prompt hub actually does

Strip away the interface and a hub does five jobs:

  1. Route. Pick the engine and the machine for each prompt.
  2. Add context. Look up what you already know about the subject before the expensive model sees the question.
  3. Run safely. Show a plan, wait for a yes, and keep each job in its own folder.
  4. Track capacity. Notice when a subscription is out of quota and fall back without being asked.
  5. Report back. Keep a log you can reopen and tell you when it's done.

None of that needs a framework. Ours is one Python file for the server (FastAPI), one for the terminal client with no dependencies at all, and a single HTML page with a script file.

Why the hub runs command lines, not APIs

The obvious design is to put everything behind LiteLLM, which speaks one API for many models. That works for local models and for paid API keys. It doesn't work for the subscriptions most people actually pay for.

A Claude Pro or Max plan reaches Claude Code through its own sign-in. Claude Code's documentation even notes that its stripped-down bare scripting mode "doesn't use your subscription login" and needs an API key instead. Codex works the same way: its non-interactive mode uses the command line's saved sign-in.

So the hub doesn't call model APIs for the main work. It runs the two command lines exactly as you would, in a job folder, and reads what they print:

# Claude Code: one JSON event per line
claude -p --output-format stream-json \
  --verbose --permission-mode auto \
  < prompt.md

# Codex: "-" reads the prompt from stdin
codex exec --sandbox workspace-write \
  --json -o result.md - < prompt.md

Claude Code's streaming output ends with a result message carrying "the final response text, cost, and session metadata." Codex prints its progress to stderr and only the final message to stdout. Those two facts are all the parsing the hub needs.

LiteLLM still earns its place. The local models sit behind it, and anything else on the server that wants an OpenAI-style endpoint can use it.

Routing: Claude first, with a real capacity signal

The order is fixed: Claude Code, then Codex, then the local model. The interesting part is knowing when to move on.

Guessing is the usual approach, for example "assume Claude is busy for 30 minutes after an error". We found something better in Claude Code's own stream. Every run emits a rate_limit_event that carries a status, the kind of window (ours was the five-hour window) and the time that window resets.

When the status stops saying allowed, the hub marks Claude unavailable until the reset time, sends the job to Codex, and shows the window on its status panel. If Codex is at capacity too, the job falls through to the local model, which writes a short draft, and the job log says plainly which engine answered. That draft is a placeholder, not a substitute: rerun the job once a subscription is back.

On a server without a GPU, the local models get two small jobs. Triage labels each request (question, code, operations, research or writing) and guesses whether files will change. A short draft happens only when both subscriptions are out.

Scroll to compare columns and rows when the table extends beyond the box.

Local job Model Time on our 48-core server
Triage Qwen 2.5, 1.5B A second or two
Short draft Hermes 3, 8B About 12 seconds

Context first: look it up before you pay for it

Before Claude or Codex sees a prompt, the hub searches our notes (a Qdrant collection built from project memory, past job briefs and audit reports) and attaches the best matches as titles and file paths. Claude can then open the files it needs instead of rediscovering them.

The first threshold we picked was too loose. It attached notes about motel Facebook pages to a question about which engine was running. A higher cutoff fixed it. Test this part with prompts that should match nothing.

Plan before run: the rule that makes a URL safe

The hub accepts prompts from a URL, so a phone shortcut or a bookmark can start work:

https://hub.example.com/?q=Summarize+yesterday%27s+backup+log&target=server&mode=ask

A link like that must never run anything by itself. Links get clicked by accident, prefetched by browsers and replayed from history. So a URL only builds a plan: the engine, the machine, the mode and the notes it will attach. The page shows the plan with a Run button. The terminal shows the same plan and waits for Enter.

The Run button carries a one-time token that only the plan response contains. Every request that changes something must be JSON, and the server sends no CORS headers, so another website can't read a plan or press Run on your behalf.

Two modes keep the blast radius small:

Scroll to compare columns and rows when the table extends beyond the box.

Mode What Claude may do
Ask Read, search and look things up on the web. Nothing on disk changes.
Work Change files inside the job's own folder, in Claude Code's auto permission mode, where a classifier reviews "most actions instead of you."

The hub never passes the flag that skips permission checks altogether, and anything outward-facing, like a deploy, a push or an email, still needs a separate confirmation.

Putting it on the internet without opening a port

The hub listens on 127.0.0.1 only. A Cloudflare Tunnel publishes it on a private hostname, and Cloudflare Access puts a sign-in in front of it, the same one we already use for the remote desktop.

One trap is worth a paragraph of its own. The tunnel connector runs on the same machine, so every request it forwards arrives from 127.0.0.1. "It came from localhost" proves nothing. The hub checks the token that Access attaches to each request instead. Cloudflare's guide is direct about it: "You should validate the token with your public key to ensure that the request came from Access and not a malicious third party."

For every request, the hub:

  1. Reads the token from the Cf-Access-Jwt-Assertion header, which Cloudflare recommends over the cookie.
  2. Fetches the public signing keys Cloudflare publishes for your team.
  3. Checks the signature, the issuer (your team domain) and the audience tag of the Access application.
  4. Checks the email against its own short allowlist.

The terminal client skips all of that. It uses a random token in a file only your account can read, and it talks to 127.0.0.1 directly.

Every way in

Scroll to compare columns and rows when the table extends beyond the box.

Where you are How you reach the hub
Browser or phone The private hostname, behind the Access sign-in
Shortcut or bookmark A URL with a prompt in it, which opens a plan, never a run
SSH on the server Type hub for the menu, or hub "your prompt"
Linux browser desktop An icon that opens the hub's terminal over SSH, with a key that can do nothing else
Windows VM Two shortcuts: the same terminal over SSH, and the web page

The desktop icon deserves a closer look, because it is the pattern to reuse. Its SSH key sits in the server's authorized_keys file with a forced command:

restrict,pty,command="/usr/local/bin/hub" ssh-ed25519 AAAA... desktop-hub

The restrict option turns off port, agent and X11 forwarding along with the rest of the extras, and pty adds back the one thing an interactive terminal needs. The forced command means the key opens the hub and nothing else. We tried sending it a shell command through that key, and nothing ran.

The Windows VM

Jobs that target the Windows VM run as a separate local account with no administrator rights, over its own SSH key. That account has its own Claude Code and Codex installs, which you sign in to once. Two Windows details cost us an hour:

  1. PowerShell's default execution policy blocks the .ps1 launchers that npm creates, so the hub calls the .cmd versions instead.
  2. A multi-line argument sent over SSH into PowerShell gets mangled, so instructions go in on stdin.

Picking a job up from your phone

Some jobs need a person halfway through. For those, the hub can reopen a job as a Claude Code Remote Control session, started in the job's folder inside a tmux session on the server:

tmux new-session -d -s hub-1015 \
  -c ~/hub/jobs/1015 \
  'claude --remote-control hub-1015'

You then pick the session up in claude.ai/code or the Claude app and carry on from there. The session keeps running on the server, so your phone can sleep. Anthropic's documentation lists Remote Control on the Pro, Max, Team and Enterprise plans and says "API keys are not supported", which is one more reason the hub runs the real command line.

The same panel checks Desktop Commander on the server and on the Windows VM and starts it if it's down. That way the device is ready when you open a chat that uses it.

Phone alerts with ntfy: how it works

The last piece is knowing when something finished, or broke, without watching a screen. We use ntfy, an open-source project that describes itself as "Send push notifications to your phone or desktop via PUT/POST." It is dual licensed under Apache 2.0 and GPLv2.

The whole model fits in one line:

curl -d "Backup done" ntfy.sh/q7k2-backups

Anything that sends that HTTP request publishes a message to a topic, and every phone subscribed to that topic gets a notification. There is no account and no topic to create first.

That's also the catch, in ntfy's own words: "the topic is essentially a password." Anyone who knows the name can read the messages and send new ones. Pick something long and random, and treat it like a credential. The examples below keep it in one variable, so the name lives in one place:

NTFY=https://ntfy.sh/long-random-topic

A few optional headers turn a bare message into a useful alert:

Scroll to compare columns and rows when the table extends beyond the box.

Header What it does
Title Bold first line of the notification
Priority 1 (min) to 5 (urgent); 3 is the default
Tags Emoji short codes such as warning, shown as icons
Click Link that opens when you tap the notification
Actions Up to three buttons
At Deliver later, from 10 seconds to 3 days ahead on ntfy.sh

Put together, they look like this:

curl -H "Title: Website down" \
  -H "Priority: 5" \
  -H "Tags: warning" \
  -H "Click: https://example.com/admin" \
  -d "The booking page did not answer." \
  "$NTFY"

You subscribe with the Android app (Google Play, F-Droid or a direct download), the iPhone app, or a web app you add to your home screen. On Android, "instant delivery" keeps a small permanent notification so messages arrive even in doze mode.

Limits and privacy

The free public server has limits worth knowing before you build on it: 250 messages per day per visitor, bursts of 60 requests refilling at one every five seconds, and attachments up to 2 MB that are deleted after three hours. Our server sends about 25 alerts a day, covering update runs, watchdog repairs, hub jobs and monitors, all through one small alert script.

ntfy's privacy policy says messages are cached on the server (12 hours by default) and that content isn't logged by default. There is no end-to-end encryption, and the policy itself suggests self-hosting for sensitive content. Our rule is simple: alerts carry a job number, a status and a link, never the prompt and never customer data. The link opens the hub, which is behind the sign-in.

Running your own ntfy server

If you need more privacy than that, run your own server from the official Docker image. Four lines of its config file do most of the work:

base-url: https://ntfy.example.com
auth-file: /var/lib/ntfy/user.db
auth-default-access: deny-all
upstream-base-url: https://ntfy.sh

The auth file switches on access control, and deny-all means nobody reads or posts without an account. Then open a shell in the container (docker exec -it ntfy sh), create a user, give it your topic and make a token:

ntfy user add alerts
ntfy access alerts your-topic rw
ntfy token add alerts

Scripts send the token as an Authorization: Bearer header. The last config line matters for iPhones: according to the self-hosting docs, an iPhone only gets instant notifications from your server if it forwards through ntfy.sh that way. Otherwise delivery can take hours.

Small-business alerts you can set up this afternoon

You don't need a prompt hub to get value out of this. Each example below fits in a scheduled task, a script or an automation tool. The shell examples assume NTFY is set as shown above.

When the booking page stops answering

Run it every five minutes from cron:

curl -fsS -m 20 -o /dev/null \
  https://example.com/book/ ||
  curl -H "Priority: 5" \
    -H "Tags: rotating_light" \
    -H "Title: Booking page down" \
    -d "The page did not answer" \
    "$NTFY"

When the nightly backup fails

Put it straight after the backup command, so it only fires on failure:

restic backup /srv/data ||
  curl -H "Priority: 4" \
    -H "Tags: floppy_disk,x" \
    -d "Backup failed on $(hostname)" \
    "$NTFY"

When a booking arrives or a payment fails

In n8n, Zapier or Make, add an HTTP request step after the trigger:

Scroll to compare columns and rows when the table extends beyond the box.

Setting New booking Failed payment or low stock
Trigger The booking form is submitted The payment provider's webhook, or a daily stock query
Request POST to your topic URL POST to your topic URL
Message New booking: name and date Order number and what went wrong
Headers Click: the booking's admin page Title: Payment failed, Priority: 4, Click: the order page

Send the name and date, never the customer's phone number or message.

When a long AI job finishes

Chain the alert to the job, so it only fires when the job succeeds:

claude -p "List the errors in this log" \
  < backup.log > summary.md &&
  curl -H "Tags: white_check_mark" \
    -d "Log summary is ready" "$NTFY"

A reminder for tomorrow

ntfy reads times like "tomorrow, 8am" in the server's time zone unless you send your own, so name it:

curl -H "At: tomorrow, 8am" \
  -H "Timezone: America/New_York" \
  -H "Title: Deposit due" \
  -d "Bank the weekend cash by 10" \
  "$NTFY"

From Windows PowerShell

$ntfy = "https://ntfy.sh/long-random-topic"
$headers = @{
  Title    = "Printer offline"
  Priority = "4"
}
Invoke-RestMethod $ntfy -Method Post `
  -Headers $headers `
  -Body "Front desk printer is down"

From a GitHub Actions workflow

Add it as the last step. Store the full topic URL as a repository secret:

- name: Alert on failure
  if: failure()
  env:
    NTFY: ${{ secrets.NTFY_URL }}
  run: |
    curl -H "Priority: 4" \
      -d "Site build failed" "$NTFY"

The cookbook has these and more, including a systemd recipe that alerts when any service fails, Python and Node versions, and the private-server setup.

What it cost, and what we'd tell you to skip

The hub adds nothing to the bill beyond what we already paid: the server, the two subscriptions and a free ntfy topic. The real cost was the evening it took, and most of that went on four bugs:

  1. A worker that vanished. Our job queue started its background worker with asyncio.create_task and kept no reference to it. Python holds only weak references to tasks, so the worker could be garbage-collected, and jobs sat queued forever. Keep the task in a set.
  2. A restart that cancelled queued jobs. The startup code marked every queued job as interrupted. Only running jobs are interrupted by a restart.
  3. Job titles from the small model. It titled a one-line question "AWS EC2 instance". Titles now come from the first sentence of the prompt.
  4. The two Windows quirks described above.

Skip the temptation to make the local model clever. On a machine without a GPU it's a sorter and a fallback. The subscriptions do the work you pay them for.

How to build yours

  1. Install and sign in to Claude Code and Codex on the server, as the account the hub will run as.
  2. Put the local models behind an OpenAI-style endpoint (Ollama alone works; LiteLLM if you want one door for several).
  3. Run the hub on 127.0.0.1. Publish it through a Cloudflare Tunnel with an Access application, and verify the Access token in the hub itself.
  4. Add the plan step, the run token and the two modes before you add anything else.
  5. Add the terminal client and one restricted SSH key per desktop.
  6. Create an ntfy topic, subscribe on your phone, and make the hub post a job number and link when each job ends.
  7. Watch it for a week before you add more engines.

The Prompt Hub Kit walks through each step with the code.

Fact-check notes and sources

  • Our build and numbers: our own records from October 15, 2026: the first job's reported cost ($0.11), the rate-limit fields seen in Claude Code's stream, local model timings on our 48-core server without a GPU, and about 25 alerts a day.
  • Claude Code non-interactive mode: Anthropic, Run Claude Code programmatically (print mode, the three output formats, the final result message, auto permission mode, and bare mode not using the subscription login).
  • Remote Control: Anthropic, Remote Control (continue a local session from claude.ai/code or the app; subscription plans; API keys not supported).
  • Codex non-interactive mode: OpenAI, Non-interactive mode (progress on stderr, final message on stdout, saved sign-in).
  • Cloudflare Access tokens: Cloudflare, Validate JWTs (header over cookie, the team signing-keys URL, audience tag, issuer).
  • Forced commands: OpenBSD, sshd(8), AUTHORIZED_KEYS FILE FORMAT (the restrict, pty and command options).
  • asyncio task references: Python, asyncio.create_task ("Save a reference to the result of this function, to avoid a task disappearing mid-execution").
  • ntfy: project README (description, licenses); Publishing (topics as passwords, headers, priorities, scheduled delivery, the Timezone header, limits on ntfy.sh); Phone apps (Android, iOS, instant delivery); Privacy (12-hour cache, no content logging by default, no end-to-end encryption); Configuration (Docker image, auth file, deny-all access, user, access and token commands, the iPhone upstream setting).

Related reading

This post is informational, not security-consulting advice. Check each provider's current terms before automating a subscription. Mentions of Anthropic, Claude, OpenAI, ChatGPT, Codex, Cloudflare, ntfy, Ollama, LiteLLM, Qdrant, n8n, Zapier, Make, GitHub and Microsoft are nominative fair use. No affiliation is implied.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility Statement • Privacy Policy

Last updated: April 2026