Skip to main content

Deploy full-featured web apps in minutes. Auth, payments, admin, email, database — one import.

Project description

Tidepool

    ┌─────────────────────────────────────────────┐
    │  build locally  →  deploy in 30s  →  live   │
    │       ↑                                ↓    │
    │    iterate        auth · payments · email    │
    │       ↑              db · files · http       │
    │       └──────────  <you>.tidepool.sh  ──┘   │
    └─────────────────────────────────────────────┘

A PaaS platform that is build for AI agents (and humans with interactive CLI coding tools like Claude Code). The API is meant to be self-documenting, and every feature is available first and foremost via the command line.

Tools such as auth flows, Stripe payments, admin and database ORM, multi-page routing, file storage, email, a key-value database, background tasks, etc are all built and documented with AI agents in mind. These tools are available via a single tp object; they are meant to provide abstractions for infrastructure that is hard for an AI to set up (eg, third party subscriptions that take lots of clicks/config). We abstract these away so that the AI can move fast in terms of core site design, logic, and styling. Logging and API errors/warnings/tips are meant to be as detailed as possible so that an agent, on its own, can quickly understand and solve whatever problems come up.

Pods (which are always-on Fly machines at <name>.tidepool.sh) scale horizontally to 10+ replicas with shared Postgres/Redis/R2 — enough for a Substack clone at 20k–50k DAU or a Reddit-style site at 5k–15k DAU. The goal: a membership site, a SaaS dashboard, or a social platform in an afternoon. No servers, no Docker, no AWS. Stack: Django, Redis, Postgres, Fly Machines, Cloudflare R2, Stripe, Porkbun.

Quickstart

Install

pip install tidepool

Init a pod

tidepool init my-blog
cd my-blog

Creates a directory with a default main.py:

import tp
tp.page('/', '<h1>Hello from Tidepool!</h1>')

Develop locally

tidepool dev
# Pod runs at http://localhost:8000

The dev server replicates production pod behavior: file I/O goes to ./tp_data/files/, tp.db persists to a JSON file, tp.state is readable at ?format=json. Stripe, R2, and email are optional — the app runs without them.

Deploy

tidepool --url https://tidepool.sh register --email you@example.com
# verify email, then:
tidepool deploy
# Pod is live at https://my-blog.tidepool.sh in ~30 seconds

deploy auto-discovers source files and uploads tp_data/files/ (images, media) and tp_data/secrets.json automatically.

Push & Pull

Pull a live pod to develop locally — all state comes with it:

tidepool pull abc123
# Creates my-blog/ with source files + tp_data/ (db, secrets, files)
cd my-blog
tidepool dev
# Edit code, add data, test locally...
tidepool push              # pushes everything back (hash remembered from pull)

pull downloads source files, tp.dbtp_data/db.json, tp.secretstp_data/secrets.json, and pod files → tp_data/files/. The dev server reads all of these natively — no conversion needed.

push auto-discovers source files (same as deploy) and uploads them along with tp_data/db.json (merge by default), tp_data/secrets.json, and all files in tp_data/files/. Pushing source files triggers a pod restart. Use -y to skip the confirmation prompt.

tidepool pull abc123 --dir .   # pull into current directory instead of a subdirectory
tidepool push abc123           # explicit hash (overrides remembered hash)
tidepool push --file main.py   # push specific files instead of auto-discover
tidepool push --secret STRIPE_KEY=sk_xxx               # override a secret
tidepool push --replace-db     # replace all db keys instead of merging
tidepool push --sync           # delete remote files not present locally
tidepool push -y               # skip confirmation prompt

.tpignore

Create a .tpignore file to exclude files from deploy and push (same syntax as .gitignore):

# Directories (trailing slash)
data/
notebooks/

# File patterns
*.csv
*.npy
*.pkl
*.parquet
pipeline.py
build_log.*

Always ignored regardless of .tpignore: tp_data/, __pycache__/, .git/, venv/, .venv/, node_modules/, dotfiles, and build_log.*. Files over 50MB are skipped automatically with a warning.

tidepool init generates a starter .tpignore. For existing projects, create one before your first deploy or push to avoid uploading data files, models, or build artifacts.

Eject Mode

For full control over the runtime, eject the internals into your project:

tidepool eject
# Copies tp_runtime.py, tp_server.py, tp_backend.py, tp_templates/ into your project

These files are now yours to modify. tidepool dev, deploy, and push auto-detect eject mode when tp_server.py exists in the project directory — no flags needed. To undo, delete the ejected files.

Runtime Tools

Use import tp at the top of every .py file. main.py runs once at startup to configure the pod — set auth, payments, seed data, register routes. The server dispatches requests directly to handlers.

Name Description Usage
tp.route Register a request handler with path params @tp.route('/post/:slug', methods=['GET'])
tp.page Register a static HTML page (no handler) tp.page('/about', '<h1>About</h1>')
tp.auth Full auth system. Presets: 'paywall' (pay-first + magic link) or 'standard' (email/password) tp.auth = 'paywall' or tp.auth = 'standard'
tp.payments Stripe subscriptions and one-time purchases (in cents) tp.payments = {products: [{id: 'pro', price: 500, recurring: 'month'}]}
tp.admin Auto-generated admin panel at /_admin/ tp.admin = {users: ['admin@example.com']}
tp.create_user Create user with hashed password (idempotent) tp.create_user('sam@x.com', 'pass', subscriptions={'pro': True})
tp.db Key-value store, 1GB limit, persisted across runs tp.db.set('post:slug', {...}) / tp.db.get('post:slug')
tp.files File storage (R2 in prod, 50GB), served at /_files/ tp.files.write('photo.jpg', data) / tp.files.read('photo.jpg')
tp.email Send email with optional HTML and attachments tp.email('user@x.com', 'Subject', 'body', html='<p>hi</p>')
tp.http HTTP client (same API as requests), 200 req/60s, SSRF-protected tp.http.post(url, json=payload, headers={...})
tp.markdown Convert markdown to HTML (tables, code, footnotes) html = tp.markdown('# Hello\n\nWorld')
tp.secrets Read-only dict of deploy-time credentials api_key = tp.secrets['STRIPE_KEY']
tp.state Public app state dict, readable at ?format=json tp.state = {'status': 'live'}
tp.publish Update public JSON state (ETag-supported polling) tp.publish({'messages': msgs})
tp.background Background tasks (max 5). seconds<=0: once, >0: loop @tp.background(seconds=3600)

Handler return values: str → 200 HTML, dict/list → 200 JSON, int → status code, tuple(body, status) → body + status, None → 303 redirect, generator → SSE stream.

Request object: Handler receives (req, **params). Attributes: req.path, req.method, req.query, req.user (dict or None), req.body (dict), req.files (dict of upload metadata — file data auto-saved to tp.files).

Auth details

Auth presets: tp.auth = 'paywall' for payment-first apps (no signup form, accounts auto-created at checkout, magic link for return logins — pair with tp.payments). tp.auth = 'standard' for traditional email/password signup with confirmation, reset, and magic link. Customize after setting a preset: tp.auth['required'] = ['/dash/*'], tp.auth['theme'] = {'accent': '#e74c3c'}. Or pass a full dict for manual control: tp.auth = {required: ['/dash/*'], signup: True, reset: True, oauth: ['google']}.

Email confirmation on by default — signup_confirm: False to disable. req.user in handlers gives the logged-in user including subscriptions, purchases, and avatar_url (from Google profile). Theme: theme: {page: '<html>...{content}...</html>'} wraps auth pages in your layout; {content} receives the form, {title} the page title. Simpler: theme: {accent: '#color', css: '...'}.

Google OAuth setup: Add google_client_id and google_client_secret to tp_data/secrets.json. Get credentials at Google Cloud Console → Create OAuth 2.0 Client ID (Web application). Add authorized redirect URI: http://localhost:8000/_auth/oauth/google/callback for dev, https://yourdomain.com/_auth/oauth/google/callback for prod. That's it — the server handles the rest.

Payments details

Set tp.payments = {products: [{id: 'pro', name: 'Pro', price: 500, recurring: 'month'}]}. Users pay at /_pay/pro, manage subscriptions at /_pay/portal. recurring: 'month'/'year' for subscriptions; omit for one-time. Dev mode simulates purchases instantly. Prod requires tidepool stripe-connect (one-time setup).

Admin details

Set tp.admin = {models: {post: {fields: {title: 'string', body: 'text', tier: 'choice:free,pro'}, display: ['title']}}}. Field types: string, text, bool, number, date, choice:a,b,c. Plus read-only views of users, payments, emails, files. If not set, auto-inferred from tp.db key patterns. Admin access control: with tp.auth configured, set users: ['admin@example.com'] to restrict to specific emails (otherwise any logged-in user can access). Without tp.auth, admin is open in dev and key-gated in prod (key printed in server logs at startup, access via /_admin?key=<key>).

Background tasks

@tp.background()  # runs once at startup
def migrate(tp):
    if not tp.db.get('_migrated_v2'):
        for key, val in tp.db.prefix('post:'):
            val['version'] = 2
            tp.db.set(key, val)
        tp.db.set('_migrated_v2', True)

@tp.background(seconds=3600)  # every hour
def send_digest(tp):
    for email, user in tp.users().items():
        if user.get('subscriptions', {}).get('digest'):
            posts = tp.db.prefix('post:', reverse=True, limit=5)
            tp.email(email, 'Hourly Digest', '\n'.join(t for _, t in posts))

Server-Sent Events (SSE)

Return a generator from any route handler to stream real-time events:

@tp.route('/feed/live')
def live_feed(req):
    def stream():
        last_count = 0
        while True:
            messages = tp.db.prefix('msg:', reverse=True, limit=20)
            if len(messages) != last_count:
                last_count = len(messages)
                yield {'messages': [m for _, m in messages]}
            time.sleep(2)
    return stream()

Client-side: new EventSource('/feed/live'). Max 100 concurrent SSE connections per pod.

Static files & templating

  • Static files — Files in static/ alongside main.py are served at /static/<path>.
  • Jinja2 — Pre-installed. from jinja2 import Environment, FileSystemLoader; env = Environment(loader=FileSystemLoader('templates'), autoescape=True). Render: env.get_template('page.html').render(posts=p).
Group Subcategory Lines Files
SDK Runtime 446 tp_runtime.py (310), tp_backend.py (136)
CLI 585 cli.py
Dev Server 1,052 tp_server.py
2,083
Core Views & API 900 views.py
Engine 1,205 domains (439), models (155), machines (155), backend_prod (132),
billing (128), storage (77), tasks (63), middleware (42), apps (14)
Admin 427 pod_admin (322), admin (105)
Frontend 670 templates/ (7 HTML files)
Config & Mgmt 335 config/ (161), management commands (174)
3,537
Tests SDK 430 test_runtime.py
Core 700 test_api.py
1,130
Infra 308 fly.toml (45), nginx.conf (59), scripts (85), pod_entrypoint (68),
Dockerfiles (41), manage.py (10)
Total 7,058

Repo structure

tidepool/
├── sdk/                 Open-source runtime layer
│   ├── tp_runtime.py    The tp object (route, page, auth, payments, db, files, email, etc.)
│   ├── tp_backend.py    Backend interface + LocalBackend (dev storage)
│   ├── tp_server.py     Unified server (runs in both dev and prod)
│   ├── tp_templates/    Auth form HTML templates
│   └── cli.py           CLI: init, dev, eject, deploy, push, pull, etc.
├── core/                Platform Django app
│   ├── models.py        Agent, Pod, Environment
│   ├── views.py         API + billing endpoints
│   ├── pod_admin.py     Framework-agnostic admin panel rendering
│   ├── backend_prod.py  ProdBackend (Postgres/R2 storage for pods)
│   ├── domains.py       Domain search (WHOIS), registration (Porkbun) + Fly cert management
│   ├── billing.py       Stripe checkout, autoreload, webhooks
│   ├── machines.py      Fly Machines API client (always-on)
│   ├── tasks.py         Daily credit deduction
│   ├── storage.py       R2 with local fallback
│   ├── middleware.py     Rate limiting
│   ├── admin.py         Django admin config
│   ├── apps.py          App config
│   ├── templates/       Landing, API docs, quickstart, pod page, billing
│   └── management/      scheduler, seed_environments, activate_agent, loadtest
├── config/              Django configuration (settings, urls, wsgi)
├── fly.toml             Control plane config (web + scheduler processes)
├── Dockerfile           Control plane container
├── Dockerfile.pod       Always-on pod container
├── pod_entrypoint.py    Pod boot: fetch code, start server
├── nginx.conf           Reverse proxy config (subdomain routing + load balancing)
├── entrypoint.sh        Multi-role entrypoint (web/scheduler/release)
├── run.sh               Local dev launcher
└── build.sh             Setup script (migrations, seed, superuser)

Local dev

Prerequisites: Python 3.11+, Redis running locally (brew install redis && redis-server).

python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py seed_environments
./run.sh  # starts Django dev server (port 8000) + scheduler

Admin: http://localhost:8000/ — username admin, password tidepool123 (or SUPERUSER_PASSWORD env var). To activate an agent without Stripe: python manage.py activate_agent <hash>.

Local dev uses SQLite and ./media/ for file storage. Stripe, R2, and email are all optional — the app runs without them.

Deployment

Two Fly apps: tidepool (control plane: Django + scheduler) and tidepool-pods (always-on Machines, one per pod).

First deploy

brew install flyctl && fly auth login

# 1. Create apps + Postgres + Redis
fly launch --copy-config --no-deploy
fly postgres create --name tidepool-db
fly postgres attach tidepool-db              # sets DATABASE_URL
fly redis create --name tidepool-redis       # sets REDIS_URL
fly apps create tidepool-pods

# 2. Generate secrets
fly tokens create org <your-org>             # org token (not deploy token!)
python -c 'import secrets; print(secrets.token_hex(32))'  # FLY_INTERNAL_SECRET

# 3. Set secrets on the control plane
fly secrets set \
  DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')" \
  SUPERUSER_PASSWORD="your-admin-password" \
  FLY_API_TOKEN="<org token>" \
  FLY_INTERNAL_SECRET="<hex>" \
  CONTROL_PLANE_URL="https://tidepool.fly.dev" \
  FLY_PODS_APP="tidepool-pods"

# 4. Build and push the pod image
fly deploy -a tidepool-pods --dockerfile Dockerfile.pod --no-cache
# This creates a temporary machine — destroy it after the image is pushed:
fly machines list -a tidepool-pods
fly machines destroy <machine-id> --force -a tidepool-pods

# 5. Deploy the control plane
fly deploy

Automated deploys (default)

Pushing to main triggers the GitHub Actions workflow (.github/workflows/fly-deploy.yml). This is the default deployment method.

  1. Control plane — always deployed via flyctl deploy --remote-only.
  2. Pod image — rebuilt and pushed to registry.fly.io/tidepool-pods:latest only when pod-relevant files change (sdk/, core/, config/, pod_entrypoint.py, manage.py, Dockerfile.pod, requirements-pod.txt, .github/workflows/).

Running pods pick up the new image on next restart (kill + activate, or new machine creation).

Required GitHub secret: FLY_API_TOKEN (set in repo Settings → Secrets → Actions).

Manual deploys

For one-off deploys or debugging, you can deploy manually from the command line.

# Control plane:
fly deploy

# Pod image (builds remotely, pushes to registry):
fly deploy -a tidepool-pods --dockerfile Dockerfile.pod --no-cache
# This creates a temporary machine — destroy it after the image is pushed:
fly machines list -a tidepool-pods
fly machines destroy <machine-id> --force -a tidepool-pods

# Restart a running pod to pick up the new image:
# Kill + reactivate via API, or the pod picks up registry.fly.io/tidepool-pods:latest on next machine creation.

Custom domain

fly certs add tidepool.sh -a tidepool
fly certs add "*.tidepool.sh" -a tidepool    # wildcard for pod subdomains

# In Cloudflare DNS (DNS-only mode):
#   A    @  → <fly ipv4>
#   AAAA @  → <fly ipv6>
#   A    *  → <fly ipv4>
#   AAAA *  → <fly ipv6>
#   CNAME _acme-challenge → <app>.flydns.net

fly secrets set CONTROL_PLANE_URL="https://tidepool.sh"

Email setup (Resend)

# Add + verify domain at resend.com, add DNS records in Cloudflare:
# TXT resend._domainkey → DKIM key
# TXT send → SPF record
# MX send → feedback-smtp.us-east-1.amazonses.com

fly secrets set \
  EMAIL_HOST_PASSWORD="re_YOUR_RESEND_API_KEY" \
  DEFAULT_FROM_EMAIL="noreply@tidepool.sh"

Optional secrets

# Stripe (billing)
fly secrets set STRIPE_SECRET_KEY="sk_..." STRIPE_WEBHOOK_SECRET="whsec_..."

# R2 (file storage — falls back to local without these)
fly secrets set R2_ACCESS_KEY="..." R2_SECRET_KEY="..." R2_BUCKET="tidepool" R2_ENDPOINT="https://...r2.cloudflarestorage.com"

# Google OAuth (for pod auth flows — pods read from their own tp_data/secrets.json)
# These env vars are fallbacks if secrets.json doesn't have google_client_id / google_client_secret
fly secrets set GOOGLE_OAUTH_CLIENT_ID="..." GOOGLE_OAUTH_CLIENT_SECRET="..."

# Porkbun (domain registration for pods)
fly secrets set PORKBUN_API_KEY="pk1_..." PORKBUN_API_SECRET="sk1_..."

Gotchas

  • Token type matters. The Machines API needs an org token (fly tokens create org), not a deploy token. Wrong token → 401 on pod creation.
  • Pod image deploy creates temp machines. Manual fly deploy -a tidepool-pods creates a fly-managed machine. Destroy it after — pod machines are created by the control plane via the Machines API. The CI workflow avoids this by using flyctl auth docker + docker build + docker push.

Technical Notes

  • No DRF. JsonResponse + @csrf_exempt function views + api_auth decorator.
  • IDs are 8-char a-z0-9 hashes for Agent, Pod, and Environment. Retry on collision.
  • Pod code is multi-file: files JSONField stores [{"name": "main.py", "content": "..."}]. Binary files use {"encoding": "base64"}. Subdirectory paths are preserved.
  • Always-on architecture: each pod is a persistent Fly Machine running sdk/tp_server.py. main.py runs once at startup to register routes (@tp.route), static pages (tp.page), auth, payments, seed data. The server dispatches requests directly to handlers — main.py is never re-executed. Pod image is ~510MB (33 pre-installed Python packages); ~7.4GB ephemeral scratch disk per machine.
  • Backend interface: sdk/tp_backend.py defines a storage abstraction (db, files, secrets, email, sessions). LocalBackend uses JSON files in tp_data/ for dev. core/backend_prod.py uses Postgres/R2/Redis for prod. Secrets are read-only at runtime; state and users/purchases are stored as db rows for concurrent safety.
  • Horizontal scaling: each pod supports 1–10 replicas (Fly Machines). Set via PATCH /api/pods/<hash> {replicas: N} or +/- buttons on the billing page. Traffic routes to the first machine; proper load balancing is a future improvement. Each replica costs the same as the base pod.
  • Subdomain routing: nginx reverse proxy routes *.tidepool.sh to pod Machines via Fly internal networking. Route map files (routes_subdomain.map, routes_domain.map) map hostnames directly to machine addresses. DNS resolved at request time (no upstream blocks), so dead machines get a per-request 502 instead of crashing nginx. Wildcard DNS + wildcard TLS cert, no per-pod setup.
  • Credit deduction is time-based: ~33 credits/day per machine depending on environment. Deducted daily. 1,000 credits = $20. A 3-replica pod costs 3x.
  • 200 free credits on email verification (~6 days of a standard pod). After that, $20 = 1,000 credits. When credits hit 0, pods are paused (Machines destroyed) and data preserved.
  • Autoreload: charges card on file for $20 when balance < 100 credits. Exponential backoff on failure.
  • Billing page: authenticated via API key (POST form → signed cookie). All billing actions also available via REST API with Bearer token.
  • Pod statuses: active, paused (credits exhausted), error, killed.
  • Rate limits: 200 HTTP requests per 60s sliding window, 5 emails per handler call + 100/day per pod, 10 pod creates/hour + 100/day per agent.
  • Route handlers: @tp.route('/post/:slug') registers a handler that receives (req, slug). The server matches paths and dispatches directly — no re-execution.
  • File uploads: multipart POST data is auto-saved to tp.files and served at /_files/<name>. req.files in route handlers contains uploaded file metadata.
  • Per-user state: req.user in route handlers contains the authenticated user's session, including subscriptions and purchased dicts. Gate content by tier with a single dict lookup.
  • Stripe customer portal: /_pay/portal redirects authenticated users to Stripe's self-service portal. Zero code required.
  • All pod state is in Postgres and R2 (users, purchases, db, state, files). Pod Machines can be recreated from stored state at any time. tp.secrets are set at deploy time and loaded read-only.
  • Auto-inferred admin: if tp.admin is not set, the runtime scans tp.db for model:id key patterns and auto-generates an admin panel config.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tidepool-0.1.0-py3-none-any.whl (47.9 kB view details)

Uploaded Python 3

File details

Details for the file tidepool-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: tidepool-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 47.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for tidepool-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bddfbca7df54e1d9cf859da05cef666b5e95fb15180ab769bdd22a201e58a6f0
MD5 1a770fc113fdfaee2a8a8c3ca0b91738
BLAKE2b-256 5ff03fe0ae983cc5780517a3c0b16ee1320448c8c390406388c1014e0938f709

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page