From 4801778b3169a3adaed735207b4754a48c95e559 Mon Sep 17 00:00:00 2001 From: matcha Date: Thu, 1 Oct 2026 07:23:09 +0000 Subject: [PATCH 01/32] Initial commit --- README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f1c219 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# phast + +Phast control plane: Python + Prefab + MCP \ No newline at end of file From db4b8a9450f9b24163be90712d29c32d5c62f0b5 Mon Sep 17 00:00:00 2001 From: matcha Date: Thu, 1 Oct 2026 07:23:46 +0000 Subject: [PATCH 02/32] Bootstrap Phast agent scope --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43ef2c8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md + +## Scope + +Build and maintain Phast as a deliberately small Python + Prefab + MCP application platform. + +## Product model + +- `my.phast.app` is the owner control plane. +- Each `app.phast.app` is independently useful and independently connectable. +- `/` is the human Prefab UI by default. +- `/mcp` is the agent interface by default. +- A Prephab is a cloneable Python + Prefab + MCP application shape. + +## Working rules + +- Prefer boring, explicit Python over framework sprawl. +- Keep one source of truth for app state and capabilities. +- Do not fake auth or security; mark seams explicitly. +- No delete path until it is deliberately designed. +- Commit small coherent changes to `main` and leave the system simpler. From 0b7e593da728e431ef9b339c418cfd0b5c80a933 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:36:35 +0000 Subject: [PATCH 03/32] Make Phast repository canonical --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f1c219..4cda70a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ # phast -Phast control plane: Python + Prefab + MCP \ No newline at end of file +Canonical source of truth for Phast. + +- `phast.app` is the public product surface. +- `my.phast.app` is the owner/control surface. +- This repository owns Phast product source and web assets. +- Phast does not own a standalone Fly app and must not be embedded in the Boring runtime. +- Public web bytes are served from the Teahub Gitea/Pages machine, following the same source-owned pattern as `matcha/persimmons`. + +The Python control-plane source is retained here as product source. Live backend publication is a separate concern from static site publication and must not silently couple Phast back into Boring. From be01f22d8b4398d67a2359947a69aa10e8e234eb Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:36:37 +0000 Subject: [PATCH 04/32] Import canonical Phast runtime source --- __init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 __init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ef13255 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +"""Phast application runtime.""" From ee5f12cd42f58878c2d8a5c4071c9a2308f39d32 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:36:39 +0000 Subject: [PATCH 05/32] Import canonical Phast manifest --- phast.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 phast.json diff --git a/phast.json b/phast.json new file mode 100644 index 0000000..36d2302 --- /dev/null +++ b/phast.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "kind": "phast.app", + "slug": "my-phast", + "name": "My Phast", + "runtime": "python", + "base_url": "https://my.phast.app", + "human": {"route": "/"}, + "mcp": {"route": "/mcp"}, + "prephab": {"version": 1} +} From 0c4874a087344f82a16d81db293927a3c3d3164b Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:36:55 +0000 Subject: [PATCH 06/32] Import canonical Phast landing page --- index.html | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 index.html diff --git a/index.html b/index.html new file mode 100644 index 0000000..4c0245d --- /dev/null +++ b/index.html @@ -0,0 +1,73 @@ + + + + + + + + phast + + + + + + +
+
+ phast. + +
+
+
+ +

make something.
phast.

+
+
+ Connect your agent +

Bring the intelligence. Phast turns it into something you can use.

+
+
+
ready to makeidea → app
+
+
copied
+ + + From eccfaaa7c325837118b87366508a03df7f5bd114 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:37:15 +0000 Subject: [PATCH 07/32] Import canonical Phast control-plane source --- server.py | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 server.py diff --git a/server.py b/server.py new file mode 100644 index 0000000..25f7127 --- /dev/null +++ b/server.py @@ -0,0 +1,148 @@ +"""Phast public landing and fail-closed owner control plane.""" +from __future__ import annotations + +import os +from pathlib import Path + +import uvicorn +from fastmcp import FastMCP +from prefab_ui.app import PrefabApp +from prefab_ui.components import Badge, Button, Card, Code, Column, Heading, Row, Separator, Text +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse + +try: + from .registry import AppRegistry, RegistryUnavailable, registry_from_runtime +except ImportError: + from registry import AppRegistry, RegistryUnavailable, registry_from_runtime + +OWNER_HOST = "my.phast.app" +AUTH_BLOCKER = "Owner authentication is not configured; app mutations are disabled." + +try: + registry: AppRegistry | None = registry_from_runtime() + registry_blocker: str | None = None +except RegistryUnavailable as error: + registry = None + registry_blocker = str(error) + +mcp = FastMCP( + "My Phast", + instructions="Owner control plane for Phast app manifests. Mutations fail closed until owner auth is installed.", +) + +def _require_registry() -> AppRegistry: + if registry is None: + raise RegistryUnavailable(registry_blocker or "durable registry is offline") + return registry + +def _require_owner_auth() -> None: + raise PermissionError(AUTH_BLOCKER) + +@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def phast_status() -> dict: + return { + "ok": True, + "service": "phast", + "url": "https://phast.app", + "owner_url": "https://my.phast.app", + "mcp": "https://my.phast.app/mcp", + "registry_ready": registry is not None, + "registry_blocker": registry_blocker, + "mutations_ready": False, + "auth_blocker": AUTH_BLOCKER, + } + +@mcp.tool(name="apps_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def apps_list() -> dict: + return {"apps": _require_registry().list()} + +@mcp.tool(name="apps_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def apps_get(slug: str) -> dict: + result = _require_registry().get(slug) + if result is None: + raise KeyError(f"unknown app: {slug}") + return result + +@mcp.tool(name="apps_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) +def apps_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: + _require_owner_auth() + return _require_registry().create(slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) + +@mcp.tool(name="apps_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) +def apps_update(slug: str, name: str | None = None, base_url: str | None = None, + human_route: str | None = None, mcp_route: str | None = None) -> dict: + _require_owner_auth() + return _require_registry().update(slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) + +def control_app() -> PrefabApp: + apps = registry.list() if registry is not None else [] + with PrefabApp(title="My Phast", css_class="max-w-4xl mx-auto p-6", mode="light") as result: + with Column(gap=5): + with Row(gap=3, justify="between", align="center"): + with Column(gap=1): + Text("PHAST · OWNER CONTROL PLANE", code=True) + Heading("My Phast", level=1) + Badge("read-only" if registry is not None else "setup required", variant="warning") + Text("Your apps share one boring convention: a human surface at / and an agent surface at /mcp.") + with Card(css_class="p-5"): + with Column(gap=3): + with Row(gap=2, justify="between", align="center"): + Heading("Apps", level=2) + Badge(str(len(apps)), variant="secondary") + if apps: + for item in apps: + with Card(css_class="p-4"): + with Row(gap=3, justify="between", align="center"): + with Column(gap=1): + Heading(item["name"], level=3) + Text(item["base_url"], code=True) + Badge(item["runtime"], variant="outline") + Text(f"human {item['human']['route']} · mcp {item['mcp']['route']}") + else: + Text("No durable app registry is available yet.") + with Card(css_class="p-5"): + with Column(gap=3): + Heading("Control-plane readiness", level=2) + Text(registry_blocker or "Durable SQLite registry is mounted and readable.") + Text(AUTH_BLOCKER) + Button("Create app", disabled=True) + Separator() + Text("Agent interface", bold=True) + Code("https://my.phast.app/mcp", language="text") + Text("Mutations stay disabled until both a Fly volume and accepted owner authentication exist.") + return result + +@mcp.tool(name="my_phast_app", app=True, annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def my_phast_app() -> PrefabApp: + return control_app() + +@mcp.custom_route("/", methods=["GET"]) +async def homepage(request: Request): + host = request.headers.get("host", "").split(":", 1)[0].lower() + if host == OWNER_HOST: + return HTMLResponse(control_app().html(renderer_mode="bundled")) + return HTMLResponse(Path(__file__).with_name("index.html").read_text()) + +@mcp.custom_route("/healthz", methods=["GET"]) +async def health(_: Request): + return JSONResponse({ + "ok": True, + "service": "phast", + "source_sha": os.environ.get("SOURCE_SHA", "local"), + "control_plane": { + "registry_ready": registry is not None, + "mutations_ready": False, + "blockers": [value for value in (registry_blocker, AUTH_BLOCKER) if value], + }, + }) + +app = mcp.http_app( + path="/mcp", + transport="streamable-http", + stateless_http=True, + allowed_hosts=["phast.app", "www.phast.app", "my.phast.app", "localhost", "testserver"], +) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) From 5153a61242b7ffd679e3a722c873acf052e019ca Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:37:17 +0000 Subject: [PATCH 08/32] Import canonical Phast registry source --- registry.py | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 registry.py diff --git a/registry.py b/registry.py new file mode 100644 index 0000000..eff6a8b --- /dev/null +++ b/registry.py @@ -0,0 +1,157 @@ +"""Durable SQLite registry for Phast app manifests.""" +from __future__ import annotations + +import os +import re +import sqlite3 +from contextlib import contextmanager +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit + +SCHEMA_VERSION = 1 +_SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + +class RegistryUnavailable(RuntimeError): + pass + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + +def _validate_slug(value: str) -> str: + value = value.strip() + if not _SLUG.fullmatch(value): + raise ValueError("slug must be 1-63 lowercase letters, numbers, or hyphens") + return value + +def _validate_name(value: str) -> str: + value = value.strip() + if not value or len(value) > 100: + raise ValueError("name must be 1-100 characters") + return value + +def _validate_base_url(value: str) -> str: + value = value.strip().rstrip("/") + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.netloc or parsed.path or parsed.query or parsed.fragment: + raise ValueError("base_url must be an HTTPS origin without a path") + return value + +def _validate_route(value: str, field: str) -> str: + value = value.strip() + if not value.startswith("/") or value.startswith("//") or "?" in value or "#" in value: + raise ValueError(f"{field} must be an absolute path without query or fragment") + return value + +def _manifest(row: sqlite3.Row) -> dict: + return { + "schema_version": SCHEMA_VERSION, + "kind": "phast.app", + "slug": row["slug"], + "name": row["name"], + "runtime": "python", + "base_url": row["base_url"], + "human": {"route": row["human_route"]}, + "mcp": {"route": row["mcp_route"]}, + "prephab": {"version": 1}, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + +class AppRegistry: + def __init__(self, database: str | Path): + self.database = Path(database) + self.database.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database, timeout=5) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + with connection: + yield connection + finally: + connection.close() + + def _initialize(self) -> None: + with self._connection() as connection: + connection.execute("PRAGMA journal_mode = WAL") + connection.execute(""" + CREATE TABLE IF NOT EXISTS apps ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_url TEXT NOT NULL UNIQUE, + human_route TEXT NOT NULL, + mcp_route TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + def list(self) -> list[dict]: + with self._connection() as connection: + rows = connection.execute("SELECT * FROM apps ORDER BY slug").fetchall() + return [_manifest(row) for row in rows] + + def get(self, slug: str) -> dict | None: + slug = _validate_slug(slug) + with self._connection() as connection: + row = connection.execute("SELECT * FROM apps WHERE slug = ?", (slug,)).fetchone() + return _manifest(row) if row else None + + def create(self, *, slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: + values = ( + _validate_slug(slug), _validate_name(name), _validate_base_url(base_url), + _validate_route(human_route, "human_route"), _validate_route(mcp_route, "mcp_route"), + ) + timestamp = _now() + try: + with self._connection() as connection: + connection.execute( + "INSERT INTO apps (slug, name, base_url, human_route, mcp_route, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (*values, timestamp, timestamp), + ) + except sqlite3.IntegrityError as error: + raise ValueError("an app with this slug or base_url already exists") from error + return self.get(values[0]) + + def update(self, slug: str, *, name: str | None = None, base_url: str | None = None, + human_route: str | None = None, mcp_route: str | None = None) -> dict: + slug = _validate_slug(slug) + changes = { + "name": _validate_name(name) if name is not None else None, + "base_url": _validate_base_url(base_url) if base_url is not None else None, + "human_route": _validate_route(human_route, "human_route") if human_route is not None else None, + "mcp_route": _validate_route(mcp_route, "mcp_route") if mcp_route is not None else None, + } + changes = {key: value for key, value in changes.items() if value is not None} + if not changes: + raise ValueError("at least one field must be updated") + assignments = ", ".join(f"{key} = ?" for key in changes) + try: + with self._connection() as connection: + cursor = connection.execute( + f"UPDATE apps SET {assignments}, updated_at = ? WHERE slug = ?", + (*changes.values(), _now(), slug), + ) + if cursor.rowcount != 1: + raise KeyError(f"unknown app: {slug}") + except sqlite3.IntegrityError as error: + raise ValueError("an app with this base_url already exists") from error + return self.get(slug) + +def registry_from_runtime() -> AppRegistry: + raw = os.environ.get("PHAST_DATA_DIR", "").strip() + if not raw: + raise RegistryUnavailable("PHAST_DATA_DIR is not configured; durable registry is offline") + data_dir = Path(raw) + if not data_dir.is_dir() or not os.path.ismount(data_dir): + raise RegistryUnavailable(f"{data_dir} is not a mounted Fly volume; durable registry is offline") + return AppRegistry(data_dir / "registry.sqlite3") From 84c1925743d657f687acfee9565150d465f90604 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:37:30 +0000 Subject: [PATCH 09/32] Add static My Phast owner surface --- my/index.html | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 my/index.html diff --git a/my/index.html b/my/index.html new file mode 100644 index 0000000..3d2c566 --- /dev/null +++ b/my/index.html @@ -0,0 +1,31 @@ + + + + + +My Phast + + + +
+
phast.read-only prototype
+

My Phast

+

Owner control-plane prototype. Phast now lives in matcha/phast and is served from the Teahub Gitea/Pages machine instead of the shared Boring runtime.

+
+

Apps

No apps are registered yet.

+

Control-plane readiness

The previous runtime-backed mutation path has been retired. A live MCP/control backend should be reintroduced only from the canonical Phast source, not by embedding it in Boring.

+
+ +
+ + From d48719d0b70399e05d0cfd7c48e361fb40d43898 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:46:49 +0000 Subject: [PATCH 10/32] Explore intent-first Phast composer --- my/index.html | 167 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 19 deletions(-) diff --git a/my/index.html b/my/index.html index 3d2c566..0b826be 100644 --- a/my/index.html +++ b/my/index.html @@ -3,29 +3,158 @@ -My Phast + + +my phast + + + -
-
phast.read-only prototype
-

My Phast

-

Owner control-plane prototype. Phast now lives in matcha/phast and is served from the Teahub Gitea/Pages machine instead of the shared Boring runtime.

-
-

Apps

No apps are registered yet.

-

Control-plane readiness

The previous runtime-backed mutation path has been retired. A live MCP/control backend should be reintroduced only from the canonical Phast source, not by embedding it in Boring.

-
- +
+
+ phast. +
personal composermy.phast.app
+
+ +
+
+

connect → compose → launch

+

Make the idea executable.

+

Describe what you want. Phast turns intent into a small app recipe: intelligence, capabilities, runtime, and surfaces. Oracle can discover what exists; Crystal can find a route; Factory can do the heavy building. Phast keeps the composition.

+ +
+

What do you want to make?

+ +
+ draft first · authority later + +
+
+
+ + +
+ +
idea → composition → appsmall enough to remake
+
recipe copied
+ From c8273fcdcaec7158ae445bcb0ff37c8765c54767 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 03:23:48 +0000 Subject: [PATCH 11/32] Define Phast v0 portable tool-belt contract --- phast.json | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/phast.json b/phast.json index 36d2302..180b1b6 100644 --- a/phast.json +++ b/phast.json @@ -1,11 +1,17 @@ { "schema_version": 1, - "kind": "phast.app", - "slug": "my-phast", + "kind": "phast", + "slug": "my", "name": "My Phast", - "runtime": "python", - "base_url": "https://my.phast.app", - "human": {"route": "/"}, - "mcp": {"route": "/mcp"}, - "prephab": {"version": 1} + "host": "my.phast.app", + "surfaces": {"human": "/", "mcp": "/mcp"}, + "tools": { + "management": ["phasts_list","phasts_get","phasts_create","phasts_update","providers_search","widgets_list","widgets_add","widgets_update","widgets_remove"], + "installed": [] + }, + "connections": [], + "prefab": { + "version": 1, + "home": {"mode": "grid", "edit": true, "widgets": []} + } } From a84b96f1dcfb1ecbf83d8472b306b4f2cf857507 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 03:23:55 +0000 Subject: [PATCH 12/32] Document Phast v0 product contract --- README.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4cda70a..8540fbd 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,37 @@ Canonical source of truth for Phast. -- `phast.app` is the public product surface. -- `my.phast.app` is the owner/control surface. -- This repository owns Phast product source and web assets. -- Phast does not own a standalone Fly app and must not be embedded in the Boring runtime. -- Public web bytes are served from the Teahub Gitea/Pages machine, following the same source-owned pattern as `matcha/persimmons`. +> **Connect your tools once. Bring your tool belt anywhere.** -The Python control-plane source is retained here as product source. Live backend publication is a separate concern from static site publication and must not silently couple Phast back into Boring. +Phast gives a person one portable MCP plus a programmable Prefab home. Connect `my.phast.app/mcp` to an agent to create and manage Phasts. A created Phast has two matching surfaces: + +- `user.phast.app` — human / Prefab home +- `user.phast.app/mcp` — the user's portable MCP tool belt + +## V0 contract + +`phast.json` is deliberately boring. It is mostly: + +1. the MCP tool list and provider connections; +2. references to delegated credential authority (Persimmons, never raw secrets); +3. Prefab home/widget settings. + +The browser and chat are two editors for the same declaration. + +### Browser flow + +Home → Edit Home → Add Widget → provider search → connect if necessary → place/configure widget. + +### Agent flow + +Connect once to `my.phast.app/mcp` → create/manage `user.phast.app` → search providers → add tools/widgets → stream/taste-test Prefab in chat → persist accepted UI to the same `phast.json`. + +## Boundaries + +- **Phast composes.** It owns the portable MCP/tool belt and Prefab composition. +- **Oracle discovers.** Provider/MCP/OAuth discovery comes from the capability graph, not a hard-coded Phast directory. +- **Persimmons holds secrets.** Phast stores connection/authority references, not secret values. +- **Factory builds/operates.** Provisioning uses bounded deterministic infrastructure routes. +- **Clients are replaceable.** ChatGPT, Claude, Codex, or another MCP client should be able to use the same Phast without recollecting the user's tools. + +V0 is intentionally not arbitrary app generation. Prove the portable-tool-belt + editable-home loop first. From 137e89d384be4985874f9fb101e0fc9a997ce545 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 03:24:45 +0000 Subject: [PATCH 13/32] Build Phast v0 home and widget editor shell --- my/index.html | 173 ++++---------------------------------------------- 1 file changed, 13 insertions(+), 160 deletions(-) diff --git a/my/index.html b/my/index.html index 0b826be..5d9daec 100644 --- a/my/index.html +++ b/my/index.html @@ -1,160 +1,13 @@ - - - - - - - -my phast - - - - - - -
-
- phast. -
personal composermy.phast.app
-
- -
-
-

connect → compose → launch

-

Make the idea executable.

-

Describe what you want. Phast turns intent into a small app recipe: intelligence, capabilities, runtime, and surfaces. Oracle can discover what exists; Crystal can find a route; Factory can do the heavy building. Phast keeps the composition.

- -
-

What do you want to make?

- -
- draft first · authority later - -
-
-
- - -
- -
idea → composition → appsmall enough to remake
-
-
recipe copied
- - - +My Phast
phast.
+

Your tool belt.

Connect your tools once. Bring them anywhere.
One MCP for ChatGPT, Claude, Codex, or whatever comes next. Your home is the human side of the same composition.

+

My Home

+
PHAST · TOOL BELT

Ready to connect

Add a provider and its tools become available through this Phast MCP. Credentials stay delegated to Persimmons.

0 CONNECTED PROVIDERS
ORACLE

Provider search

Discover MCP providers, OAuth routes and capabilities from the machine graph.

DISCOVERY
+
human: my.phast.appagent: /mcp
+ + \ No newline at end of file From 0efec8fbe82523c25e7195aa3e665b17a7d1b6c3 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 03:24:53 +0000 Subject: [PATCH 14/32] Sharpen Phast portable tool-belt copy --- index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 4c0245d..cd2b59c 100644 --- a/index.html +++ b/index.html @@ -59,8 +59,8 @@ h1{margin:0;max-width:8.5ch;font-size:clamp(62px,10.5vw,176px);font-weight:500;l

make something.
phast.

- Connect your agent -

Bring the intelligence. Phast turns it into something you can use.

+ Build your Phast +

Connect your tools once. Bring your tool belt anywhere.

ready to makeidea → app
@@ -70,4 +70,4 @@ h1{margin:0;max-width:8.5ch;font-size:clamp(62px,10.5vw,176px);font-weight:500;l const connector='my.phast.app/mcp';const copyButton=document.querySelector('#copyConnector');const toast=document.querySelector('#toast');let toastTimer;function showToast(message='copied'){toast.textContent=message;toast.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.classList.remove('show'),1200)}copyButton?.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(connector);showToast('connector copied')}catch{showToast(connector)}}); - + \ No newline at end of file From 8ad4814a0d8d12cc278047ddc719aad2f65fb0f9 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 03:25:09 +0000 Subject: [PATCH 15/32] Define Phast v0 management MCP vocabulary --- server.py | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/server.py b/server.py index 25f7127..3c29977 100644 --- a/server.py +++ b/server.py @@ -28,7 +28,7 @@ except RegistryUnavailable as error: mcp = FastMCP( "My Phast", - instructions="Owner control plane for Phast app manifests. Mutations fail closed until owner auth is installed.", + instructions="Management MCP for portable Phast tool belts and Prefab homes. Mutations fail closed until owner auth and durable provisioning are installed.", ) def _require_registry() -> AppRegistry: @@ -53,35 +53,60 @@ def phast_status() -> dict: "auth_blocker": AUTH_BLOCKER, } -@mcp.tool(name="apps_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def apps_list() -> dict: +@mcp.tool(name="phasts_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def phasts_list() -> dict: return {"apps": _require_registry().list()} -@mcp.tool(name="apps_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def apps_get(slug: str) -> dict: +@mcp.tool(name="phasts_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def phasts_get(slug: str) -> dict: result = _require_registry().get(slug) if result is None: raise KeyError(f"unknown app: {slug}") return result -@mcp.tool(name="apps_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) -def apps_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: +@mcp.tool(name="phasts_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) +def phasts_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: _require_owner_auth() return _require_registry().create(slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) -@mcp.tool(name="apps_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) -def apps_update(slug: str, name: str | None = None, base_url: str | None = None, +@mcp.tool(name="phasts_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) +def phasts_update(slug: str, name: str | None = None, base_url: str | None = None, human_route: str | None = None, mcp_route: str | None = None) -> dict: _require_owner_auth() return _require_registry().update(slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) +@mcp.tool(name="providers_search", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def providers_search(query: str) -> dict: + """V0 seam: provider discovery belongs to Oracle, not a hard-coded Phast catalog.""" + return {"query": query, "source": "oracle", "status": "discovery-route-pending"} + +@mcp.tool(name="widgets_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +def widgets_list(slug: str) -> dict: + item = phasts_get(slug) + return {"phast": slug, "widgets": item.get("prefab", {}).get("home", {}).get("widgets", [])} + +@mcp.tool(name="widgets_add", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) +def widgets_add(slug: str, widget: dict) -> dict: + _require_owner_auth() + raise NotImplementedError("durable phast.json widget persistence is not installed yet") + +@mcp.tool(name="widgets_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) +def widgets_update(slug: str, widget_id: str, widget: dict) -> dict: + _require_owner_auth() + raise NotImplementedError("durable phast.json widget persistence is not installed yet") + +@mcp.tool(name="widgets_remove", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) +def widgets_remove(slug: str, widget_id: str) -> dict: + _require_owner_auth() + raise NotImplementedError("durable phast.json widget persistence is not installed yet") + def control_app() -> PrefabApp: apps = registry.list() if registry is not None else [] with PrefabApp(title="My Phast", css_class="max-w-4xl mx-auto p-6", mode="light") as result: with Column(gap=5): with Row(gap=3, justify="between", align="center"): with Column(gap=1): - Text("PHAST · OWNER CONTROL PLANE", code=True) + Text("PHAST · OWNER CONTROL PLANE", code=[REDACTED] Heading("My Phast", level=1) Badge("read-only" if registry is not None else "setup required", variant="warning") Text("Your apps share one boring convention: a human surface at / and an agent surface at /mcp.") @@ -96,7 +121,7 @@ def control_app() -> PrefabApp: with Row(gap=3, justify="between", align="center"): with Column(gap=1): Heading(item["name"], level=3) - Text(item["base_url"], code=True) + Text(item["base_url"], code=[REDACTED] Badge(item["runtime"], variant="outline") Text(f"human {item['human']['route']} · mcp {item['mcp']['route']}") else: @@ -145,4 +170,4 @@ app = mcp.http_app( ) if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) \ No newline at end of file From 3075069cebf41ffcbefe6d878da8269fa7556b84 Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 03:41:47 +0000 Subject: [PATCH 16/32] Implement Phast management and personal MCP server --- server.py | 274 +++++++++++++++++++++--------------------------------- 1 file changed, 105 insertions(+), 169 deletions(-) diff --git a/server.py b/server.py index 3c29977..edaaa42 100644 --- a/server.py +++ b/server.py @@ -1,173 +1,109 @@ -"""Phast public landing and fail-closed owner control plane.""" from __future__ import annotations - -import os +import json,os,re from pathlib import Path - -import uvicorn +import httpx,uvicorn from fastmcp import FastMCP +from fastmcp.server.dependencies import get_access_token +from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy from prefab_ui.app import PrefabApp -from prefab_ui.components import Badge, Button, Card, Code, Column, Heading, Row, Separator, Text -from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse - -try: - from .registry import AppRegistry, RegistryUnavailable, registry_from_runtime -except ImportError: - from registry import AppRegistry, RegistryUnavailable, registry_from_runtime - -OWNER_HOST = "my.phast.app" -AUTH_BLOCKER = "Owner authentication is not configured; app mutations are disabled." - -try: - registry: AppRegistry | None = registry_from_runtime() - registry_blocker: str | None = None -except RegistryUnavailable as error: - registry = None - registry_blocker = str(error) - -mcp = FastMCP( - "My Phast", - instructions="Management MCP for portable Phast tool belts and Prefab homes. Mutations fail closed until owner auth and durable provisioning are installed.", -) - -def _require_registry() -> AppRegistry: - if registry is None: - raise RegistryUnavailable(registry_blocker or "durable registry is offline") - return registry - -def _require_owner_auth() -> None: - raise PermissionError(AUTH_BLOCKER) - -@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def phast_status() -> dict: - return { - "ok": True, - "service": "phast", - "url": "https://phast.app", - "owner_url": "https://my.phast.app", - "mcp": "https://my.phast.app/mcp", - "registry_ready": registry is not None, - "registry_blocker": registry_blocker, - "mutations_ready": False, - "auth_blocker": AUTH_BLOCKER, - } - -@mcp.tool(name="phasts_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def phasts_list() -> dict: - return {"apps": _require_registry().list()} - -@mcp.tool(name="phasts_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def phasts_get(slug: str) -> dict: - result = _require_registry().get(slug) - if result is None: - raise KeyError(f"unknown app: {slug}") - return result - -@mcp.tool(name="phasts_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) -def phasts_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: - _require_owner_auth() - return _require_registry().create(slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) - -@mcp.tool(name="phasts_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) -def phasts_update(slug: str, name: str | None = None, base_url: str | None = None, - human_route: str | None = None, mcp_route: str | None = None) -> dict: - _require_owner_auth() - return _require_registry().update(slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route) - -@mcp.tool(name="providers_search", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def providers_search(query: str) -> dict: - """V0 seam: provider discovery belongs to Oracle, not a hard-coded Phast catalog.""" - return {"query": query, "source": "oracle", "status": "discovery-route-pending"} - -@mcp.tool(name="widgets_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def widgets_list(slug: str) -> dict: - item = phasts_get(slug) - return {"phast": slug, "widgets": item.get("prefab", {}).get("home", {}).get("widgets", [])} - -@mcp.tool(name="widgets_add", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) -def widgets_add(slug: str, widget: dict) -> dict: - _require_owner_auth() - raise NotImplementedError("durable phast.json widget persistence is not installed yet") - -@mcp.tool(name="widgets_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) -def widgets_update(slug: str, widget_id: str, widget: dict) -> dict: - _require_owner_auth() - raise NotImplementedError("durable phast.json widget persistence is not installed yet") - -@mcp.tool(name="widgets_remove", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) -def widgets_remove(slug: str, widget_id: str) -> dict: - _require_owner_auth() - raise NotImplementedError("durable phast.json widget persistence is not installed yet") - -def control_app() -> PrefabApp: - apps = registry.list() if registry is not None else [] - with PrefabApp(title="My Phast", css_class="max-w-4xl mx-auto p-6", mode="light") as result: - with Column(gap=5): - with Row(gap=3, justify="between", align="center"): - with Column(gap=1): - Text("PHAST · OWNER CONTROL PLANE", code=[REDACTED] - Heading("My Phast", level=1) - Badge("read-only" if registry is not None else "setup required", variant="warning") - Text("Your apps share one boring convention: a human surface at / and an agent surface at /mcp.") - with Card(css_class="p-5"): - with Column(gap=3): - with Row(gap=2, justify="between", align="center"): - Heading("Apps", level=2) - Badge(str(len(apps)), variant="secondary") - if apps: - for item in apps: - with Card(css_class="p-4"): - with Row(gap=3, justify="between", align="center"): - with Column(gap=1): - Heading(item["name"], level=3) - Text(item["base_url"], code=[REDACTED] - Badge(item["runtime"], variant="outline") - Text(f"human {item['human']['route']} · mcp {item['mcp']['route']}") - else: - Text("No durable app registry is available yet.") - with Card(css_class="p-5"): - with Column(gap=3): - Heading("Control-plane readiness", level=2) - Text(registry_blocker or "Durable SQLite registry is mounted and readable.") - Text(AUTH_BLOCKER) - Button("Create app", disabled=True) - Separator() - Text("Agent interface", bold=True) - Code("https://my.phast.app/mcp", language="text") - Text("Mutations stay disabled until both a Fly volume and accepted owner authentication exist.") - return result - -@mcp.tool(name="my_phast_app", app=True, annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def my_phast_app() -> PrefabApp: - return control_app() - -@mcp.custom_route("/", methods=["GET"]) -async def homepage(request: Request): - host = request.headers.get("host", "").split(":", 1)[0].lower() - if host == OWNER_HOST: - return HTMLResponse(control_app().html(renderer_mode="bundled")) - return HTMLResponse(Path(__file__).with_name("index.html").read_text()) - -@mcp.custom_route("/healthz", methods=["GET"]) -async def health(_: Request): - return JSONResponse({ - "ok": True, - "service": "phast", - "source_sha": os.environ.get("SOURCE_SHA", "local"), - "control_plane": { - "registry_ready": registry is not None, - "mutations_ready": False, - "blockers": [value for value in (registry_blocker, AUTH_BLOCKER) if value], - }, - }) - -app = mcp.http_app( - path="/mcp", - transport="streamable-http", - stateless_http=True, - allowed_hosts=["phast.app", "www.phast.app", "my.phast.app", "localhost", "testserver"], -) - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) \ No newline at end of file +from prefab_ui.components import Badge,Card,Column,Heading,Row,Text +from starlette.responses import HTMLResponse,JSONResponse +DATA=Path(os.getenv("PHAST_DATA_DIR","/data/phast"));DATA.mkdir(parents=True,exist_ok=True) +GITEA=os.getenv("GITEA_URL","https://teahub.xyz").rstrip("/");CID=os.environ["GITEA_OAUTH_CLIENT_ID"];SECRET=os.environ["GITEA_OAUTH_CLIENT_SECRET"];JWT=os.environ["JWT_SIGNING_KEY"] +USERS={x.strip() for x in os.getenv("ALLOWED_GITEA_USERS","matcha,chai").split(",") if x.strip()};RX=re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +class V(IntrospectionTokenVerifier): + async def verify_token(self,t): + a=await super().verify_token(t) + if not a:return None + async with httpx.AsyncClient(timeout=10) as c:r=await c.get(GITEA+"/login/oauth/userinfo",headers={"Authorization":"Bearer "+t}) + if r.status_code!=200:return None + u=str(r.json().get("preferred_username") or "") + if u not in USERS:return None + a.claims={**(a.claims or {}),"login":u};return a +def auth(base): + v=V(introspection_url=GITEA+"/login/oauth/introspect",client_id=CID,client_secret=SECRET,client_auth_method="client_secret_basic",required_scopes=["openid"]) + return OAuthProxy(upstream_authorization_endpoint=GITEA+"/login/oauth/authorize",upstream_token_endpoint=GITEA+"/login/oauth/access_token",upstream_client_id=CID,upstream_client_secret=SECRET,token_verifier=v,base_url=base,resource_base_url=base,valid_scopes=["openid","profile","email"],forward_pkce=True,forward_resource=False,token_endpoint_auth_method="client_secret_basic",jwt_signing_key=JWT,require_authorization_consent="remember",enable_cimd=True) +def actor(): + a=get_access_token();u=str(((a.claims if a else {}) or {}).get("login") or "") + if u not in USERS:raise RuntimeError("actor not enabled") + return u +def slug(s): + s=s.strip().lower() + if not RX.fullmatch(s) or s in {"my","www","mcp"}:raise ValueError("invalid slug") + return s +def path(s):return DATA/slug(s)/"phast.json" +def read(s): + p=path(s) + if not p.exists():raise KeyError("unknown Phast") + return json.loads(p.read_text()) +def write(s,d): + p=path(s);p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix(".tmp");q.write_text(json.dumps(d,indent=2,sort_keys=True)+"\n");q.replace(p);return d +def mine(s): + d=read(s) + if d.get("owner")!=actor():raise PermissionError("not your Phast") + return d +def create(s,name=""): + s=slug(s) + if path(s).exists():raise ValueError("already exists") + return write(s,{"schema_version":1,"kind":"phast","slug":s,"name":name.strip() or s,"owner":actor(),"host":s+".phast.app","surfaces":{"human":"/","mcp":"/mcp"},"tools":{"installed":[]},"connections":[],"prefab":{"version":1,"home":{"mode":"grid","edit":True,"widgets":[]}}}) +def prefab(d): + ws=d["prefab"]["home"]["widgets"] + with PrefabApp(title=d["name"],css_class="max-w-4xl mx-auto p-6",mode="light") as a: + with Column(gap=5): + with Row(justify="between",align="center"):Heading(d["name"],level=1);Badge(str(len(d["tools"]["installed"]))+" tools",variant="secondary") + Text("https://"+d["host"]+"/mcp",code=True) + if not ws: + with Card(css_class="p-5"):Heading("Blank home",level=2);Text("Add a provider or widget from Chat or my.phast.app.") + for w in ws: + with Card(css_class="p-5"):Heading(str(w.get("title") or w["id"]),level=2);Text(str(w.get("description",""))) + return a +def human(d): + ws=d["prefab"]["home"]["widgets"];cards="".join("

"+str(w.get("title") or w["id"])+"

"+str(w.get("description",""))+"

" for w in ws) or "

Blank home

Add a widget from Chat or my.phast.app.

" + return ''+d["name"]+' - phast
phast.'+d["host"]+'/mcp

'+d["name"]+'

'+cards+'
' +m=FastMCP("My Phast",version="0.1.0",instructions="Create and manage portable Phast MCP tool belts and Prefab homes.",auth=auth("https://my.phast.app"),mask_error_details=True) +@m.tool() +def phast_status()->dict:return {"ok":True,"actor":actor()} +@m.tool() +def phasts_list()->dict: + return {"phasts":[d for p in DATA.glob("*/phast.json") if (d:=json.loads(p.read_text())).get("owner")==actor()]} +@m.tool() +def phasts_get(slug:str)->dict:return mine(slug) +@m.tool() +def phasts_create(slug:str,name:str="")->dict: + d=create(slug,name);return {"phast":d,"human_url":"https://"+d["host"]+"/","mcp_url":"https://"+d["host"]+"/mcp"} +@m.tool() +def providers_search(query:str)->dict:return {"query":query,"source":"oracle","status":"adapter-next"} +@m.tool() +def widgets_add(slug:str,widget_id:str,title:str,description:str="")->dict: + d=mine(slug);ws=d["prefab"]["home"]["widgets"] + if any(w["id"]==widget_id for w in ws):raise ValueError("widget exists") + w={"id":widget_id,"title":title,"kind":"prefab","description":description};ws.append(w);write(slug,d);return w +@m.tool() +def widgets_remove(slug:str,widget_id:str)->dict: + d=mine(slug);d["prefab"]["home"]["widgets"]=[w for w in d["prefab"]["home"]["widgets"] if w["id"]!=widget_id];write(slug,d);return {"removed":widget_id} +@m.tool(app=True) +def phast_preview(slug:str)->PrefabApp:return prefab(mine(slug)) +mgmt=m.http_app(path="/mcp",transport="streamable-http",stateless_http=True);cache={} +def inst(s): + s=slug(s) + if s in cache:return cache[s] + d=read(s);x=FastMCP(d["name"],version="0.1.0",instructions="Portable personal Phast MCP.",auth=auth("https://"+d["host"]),mask_error_details=True) + @x.tool() + def phast_manifest()->dict:return read(s) + @x.tool(app=True) + def home_app()->PrefabApp:return prefab(read(s)) + cache[s]=x.http_app(path="/mcp",transport="streamable-http",stateless_http=True);return cache[s] +class Router: + async def __call__(self,scope,receive,send): + h=dict(scope.get("headers",[])).get(b"host",b"").decode().split(":")[0].lower();p=scope.get("path","/") + if h=="my.phast.app":await mgmt(scope,receive,send);return + if h.endswith(".phast.app"): + s=h[:-10] + try:d=read(s) + except Exception:await JSONResponse({"error":"unknown Phast"},status_code=404)(scope,receive,send);return + if p=="/" and scope.get("method")=="GET":await HTMLResponse(human(d))(scope,receive,send);return + await inst(s)(scope,receive,send);return + await JSONResponse({"error":"unknown host"},status_code=404)(scope,receive,send) +app=Router() +if __name__=="__main__":uvicorn.run(app,host="0.0.0.0",port=int(os.getenv("PORT","8081"))) From a94f8997b98108022986b49be0bc74906fb05e4e Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 03:58:01 +0000 Subject: [PATCH 17/32] Listen Phast sidecar on Fly private IPv6 --- server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.py b/server.py index edaaa42..a7915bd 100644 --- a/server.py +++ b/server.py @@ -106,4 +106,4 @@ class Router: await inst(s)(scope,receive,send);return await JSONResponse({"error":"unknown host"},status_code=404)(scope,receive,send) app=Router() -if __name__=="__main__":uvicorn.run(app,host="0.0.0.0",port=int(os.getenv("PORT","8081"))) +if __name__=="__main__":uvicorn.run(app,host="::",port=int(os.getenv("PORT","8081"))) From 5c5efdb163c64ebfedf923e6c70e3cdf7a2f9614 Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 04:29:19 +0000 Subject: [PATCH 18/32] Initialize FastMCP lifespan for management discovery --- server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server.py b/server.py index a7915bd..69ea9fe 100644 --- a/server.py +++ b/server.py @@ -96,6 +96,8 @@ def inst(s): cache[s]=x.http_app(path="/mcp",transport="streamable-http",stateless_http=True);return cache[s] class Router: async def __call__(self,scope,receive,send): + if scope.get("type")=="lifespan": + await mgmt(scope,receive,send);return h=dict(scope.get("headers",[])).get(b"host",b"").decode().split(":")[0].lower();p=scope.get("path","/") if h=="my.phast.app":await mgmt(scope,receive,send);return if h.endswith(".phast.app"): From f5fa61959813157047f5470aa236b732d0604499 Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 04:41:10 +0000 Subject: [PATCH 19/32] Harden personal Phast MCP provisioning --- server.py | 528 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 432 insertions(+), 96 deletions(-) diff --git a/server.py b/server.py index 69ea9fe..cf3958b 100644 --- a/server.py +++ b/server.py @@ -1,111 +1,447 @@ from __future__ import annotations -import json,os,re + +import asyncio +import json +import os +import re from pathlib import Path -import httpx,uvicorn +from typing import Any + +import httpx +import uvicorn from fastmcp import FastMCP -from fastmcp.server.dependencies import get_access_token -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier +from fastmcp.server.dependencies import get_access_token from prefab_ui.app import PrefabApp -from prefab_ui.components import Badge,Card,Column,Heading,Row,Text -from starlette.responses import HTMLResponse,JSONResponse -DATA=Path(os.getenv("PHAST_DATA_DIR","/data/phast"));DATA.mkdir(parents=True,exist_ok=True) -GITEA=os.getenv("GITEA_URL","https://teahub.xyz").rstrip("/");CID=os.environ["GITEA_OAUTH_CLIENT_ID"];SECRET=os.environ["GITEA_OAUTH_CLIENT_SECRET"];JWT=os.environ["JWT_SIGNING_KEY"] -USERS={x.strip() for x in os.getenv("ALLOWED_GITEA_USERS","matcha,chai").split(",") if x.strip()};RX=re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +from prefab_ui.components import Badge, Card, Column, Heading, Row, Text +from starlette.responses import HTMLResponse, JSONResponse, Response + +DATA = Path(os.getenv("PHAST_DATA_DIR", "/data/phast")) +DATA.mkdir(parents=True, exist_ok=True) +GITEA = os.getenv("GITEA_URL", "https://teahub.xyz").rstrip("/") +CID = os.environ["GITEA_OAUTH_CLIENT_ID"] +SECRET = os.environ["GITEA_OAUTH_CLIENT_SECRET"] +JWT = os.environ["JWT_SIGNING_KEY"] +GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") +USERS = {x.strip() for x in os.getenv("ALLOWED_GITEA_USERS", "matcha,chai").split(",") if x.strip()} +RX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + class V(IntrospectionTokenVerifier): - async def verify_token(self,t): - a=await super().verify_token(t) - if not a:return None - async with httpx.AsyncClient(timeout=10) as c:r=await c.get(GITEA+"/login/oauth/userinfo",headers={"Authorization":"Bearer "+t}) - if r.status_code!=200:return None - u=str(r.json().get("preferred_username") or "") - if u not in USERS:return None - a.claims={**(a.claims or {}),"login":u};return a -def auth(base): - v=V(introspection_url=GITEA+"/login/oauth/introspect",client_id=CID,client_secret=SECRET,client_auth_method="client_secret_basic",required_scopes=["openid"]) - return OAuthProxy(upstream_authorization_endpoint=GITEA+"/login/oauth/authorize",upstream_token_endpoint=GITEA+"/login/oauth/access_token",upstream_client_id=CID,upstream_client_secret=SECRET,token_verifier=v,base_url=base,resource_base_url=base,valid_scopes=["openid","profile","email"],forward_pkce=True,forward_resource=False,token_endpoint_auth_method="client_secret_basic",jwt_signing_key=JWT,require_authorization_consent="remember",enable_cimd=True) -def actor(): - a=get_access_token();u=str(((a.claims if a else {}) or {}).get("login") or "") - if u not in USERS:raise RuntimeError("actor not enabled") - return u -def slug(s): - s=s.strip().lower() - if not RX.fullmatch(s) or s in {"my","www","mcp"}:raise ValueError("invalid slug") - return s -def path(s):return DATA/slug(s)/"phast.json" -def read(s): - p=path(s) - if not p.exists():raise KeyError("unknown Phast") - return json.loads(p.read_text()) -def write(s,d): - p=path(s);p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix(".tmp");q.write_text(json.dumps(d,indent=2,sort_keys=True)+"\n");q.replace(p);return d -def mine(s): - d=read(s) - if d.get("owner")!=actor():raise PermissionError("not your Phast") - return d -def create(s,name=""): - s=slug(s) - if path(s).exists():raise ValueError("already exists") - return write(s,{"schema_version":1,"kind":"phast","slug":s,"name":name.strip() or s,"owner":actor(),"host":s+".phast.app","surfaces":{"human":"/","mcp":"/mcp"},"tools":{"installed":[]},"connections":[],"prefab":{"version":1,"home":{"mode":"grid","edit":True,"widgets":[]}}}) -def prefab(d): - ws=d["prefab"]["home"]["widgets"] - with PrefabApp(title=d["name"],css_class="max-w-4xl mx-auto p-6",mode="light") as a: - with Column(gap=5): - with Row(justify="between",align="center"):Heading(d["name"],level=1);Badge(str(len(d["tools"]["installed"]))+" tools",variant="secondary") - Text("https://"+d["host"]+"/mcp",code=True) - if not ws: - with Card(css_class="p-5"):Heading("Blank home",level=2);Text("Add a provider or widget from Chat or my.phast.app.") - for w in ws: - with Card(css_class="p-5"):Heading(str(w.get("title") or w["id"]),level=2);Text(str(w.get("description",""))) - return a -def human(d): - ws=d["prefab"]["home"]["widgets"];cards="".join("

"+str(w.get("title") or w["id"])+"

"+str(w.get("description",""))+"

" for w in ws) or "

Blank home

Add a widget from Chat or my.phast.app.

" - return ''+d["name"]+' - phast
phast.'+d["host"]+'/mcp

'+d["name"]+'

'+cards+'
' -m=FastMCP("My Phast",version="0.1.0",instructions="Create and manage portable Phast MCP tool belts and Prefab homes.",auth=auth("https://my.phast.app"),mask_error_details=True) + async def verify_token(self, token): + access = await super().verify_token(token) + if not access: + return None + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get( + GITEA + "/login/oauth/userinfo", + headers={"Authorization": "Bearer " + token}, + ) + if response.status_code != 200: + return None + login = str(response.json().get("preferred_username") or "") + if login not in USERS: + return None + access.claims = {**(access.claims or {}), "login": login} + return access + + +def auth(base: str) -> OAuthProxy: + verifier = V( + introspection_url=GITEA + "/login/oauth/introspect", + client_id=CID, + client_secret=SECRET, + client_auth_method="client_secret_basic", + required_scopes=["openid"], + ) + return OAuthProxy( + upstream_authorization_endpoint=GITEA + "/login/oauth/authorize", + upstream_token_endpoint=GITEA + "/login/oauth/access_token", + upstream_client_id=CID, + upstream_client_secret=SECRET, + token_verifier=verifier, + base_url=base, + resource_base_url=base, + valid_scopes=["openid", "profile", "email"], + forward_pkce=True, + forward_resource=False, + token_endpoint_auth_method="client_secret_basic", + jwt_signing_key=JWT, + require_authorization_consent="remember", + enable_cimd=True, + ) + + +def actor() -> str: + access = get_access_token() + login = str(((access.claims if access else {}) or {}).get("login") or "") + if login not in USERS: + raise RuntimeError("actor not enabled") + return login + + +def slug(value: str) -> str: + value = value.strip().lower() + if not RX.fullmatch(value) or value in {"my", "www", "mcp"}: + raise ValueError("invalid slug") + return value + + +def path(value: str) -> Path: + return DATA / slug(value) / "phast.json" + + +def read(value: str) -> dict[str, Any]: + p = path(value) + if not p.exists(): + raise KeyError("unknown Phast") + return json.loads(p.read_text()) + + +def write(value: str, data: dict[str, Any]) -> dict[str, Any]: + p = path(value) + p.parent.mkdir(parents=True, exist_ok=True) + try: + p.parent.chmod(0o700) + except OSError: + pass + tmp = p.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + tmp.chmod(0o600) + tmp.replace(p) + try: + p.chmod(0o600) + except OSError: + pass + return data + + +def mine(value: str) -> dict[str, Any]: + data = read(value) + if data.get("owner") != actor(): + raise PermissionError("not your Phast") + return data + + +def _oauth_apps() -> list[dict[str, Any]]: + if not GITEA_TOKEN: + raise RuntimeError("GITEA_TOKEN is required for Phast provisioning") + with httpx.Client(timeout=10) as client: + response = client.get( + GITEA + "/api/v1/user/applications/oauth2", + headers={"Authorization": "token " + GITEA_TOKEN, "Accept": "application/json"}, + ) + response.raise_for_status() + return list(response.json() or []) + + +def ensure_oauth_callback(value: str) -> str: + value = slug(value) + callback = f"https://{value}.phast.app/auth/callback" + apps = [app for app in _oauth_apps() if str(app.get("client_id") or "") == CID] + if len(apps) != 1: + raise RuntimeError("could not uniquely resolve the shared Phast OAuth application") + app = apps[0] + redirects = list(app.get("redirect_uris") or []) + if callback in redirects: + return callback + redirects.append(callback) + with httpx.Client(timeout=10) as client: + response = client.patch( + GITEA + f"/api/v1/user/applications/oauth2/{app['id']}", + headers={"Authorization": "token " + GITEA_TOKEN, "Accept": "application/json"}, + json={ + "name": app.get("name") or "Phast", + "redirect_uris": sorted(set(redirects)), + "confidential_client": bool(app.get("confidential_client", True)), + }, + ) + response.raise_for_status() + return callback + + +def create(value: str, name: str = "") -> dict[str, Any]: + value = slug(value) + if path(value).exists(): + raise ValueError("already exists") + # Provision auth before durable state so a failed OAuth mutation cannot leave + # a half-created personal MCP behind. + ensure_oauth_callback(value) + return write( + value, + { + "schema_version": 1, + "kind": "phast", + "slug": value, + "name": name.strip() or value, + "owner": actor(), + "host": value + ".phast.app", + "surfaces": {"human": "/", "mcp": "/mcp"}, + "tools": {"installed": []}, + "connections": [], + "prefab": {"version": 1, "home": {"mode": "grid", "edit": True, "widgets": []}}, + }, + ) + + +def prefab(data: dict[str, Any]) -> PrefabApp: + widgets = data["prefab"]["home"]["widgets"] + with PrefabApp(title=data["name"], css_class="max-w-4xl mx-auto p-6", mode="light") as app: + with Column(gap=5): + with Row(justify="between", align="center"): + Heading(data["name"], level=1) + Badge(str(len(data["tools"]["installed"])) + " tools", variant="secondary") + Text("https://" + data["host"] + "/mcp", code=True) + if not widgets: + with Card(css_class="p-5"): + Heading("Blank home", level=2) + Text("Add a provider or widget from Chat or my.phast.app.") + for widget in widgets: + with Card(css_class="p-5"): + Heading(str(widget.get("title") or widget["id"]), level=2) + Text(str(widget.get("description", ""))) + return app + + +def human(data: dict[str, Any]) -> str: + widgets = data["prefab"]["home"]["widgets"] + cards = "".join( + "

" + + str(widget.get("title") or widget["id"]) + + "

" + + str(widget.get("description", "")) + + "

" + for widget in widgets + ) or "

Blank home

Add a widget from Chat or my.phast.app.

" + return ( + '' + + "" + + data["name"] + + " - phast" + + "" + + "
phast." + + data["host"] + + "/mcp

" + + data["name"] + + "

" + + cards + + "
" + ) + + +m = FastMCP( + "My Phast", + version="0.2.0", + instructions="Create and manage portable Phast MCP tool belts and Prefab homes.", + auth=auth("https://my.phast.app"), + mask_error_details=True, +) + + @m.tool() -def phast_status()->dict:return {"ok":True,"actor":actor()} +def phast_status() -> dict: + return {"ok": True, "actor": actor(), "version": "0.2.0"} + + @m.tool() -def phasts_list()->dict: - return {"phasts":[d for p in DATA.glob("*/phast.json") if (d:=json.loads(p.read_text())).get("owner")==actor()]} +def phasts_list() -> dict: + current = actor() + rows = [] + for p in DATA.glob("*/phast.json"): + try: + data = json.loads(p.read_text()) + except Exception: + continue + if data.get("owner") == current: + rows.append(data) + return {"phasts": sorted(rows, key=lambda item: item["slug"])} + + @m.tool() -def phasts_get(slug:str)->dict:return mine(slug) +def phasts_get(slug: str) -> dict: + return mine(slug) + + @m.tool() -def phasts_create(slug:str,name:str="")->dict: - d=create(slug,name);return {"phast":d,"human_url":"https://"+d["host"]+"/","mcp_url":"https://"+d["host"]+"/mcp"} +def phasts_create(slug: str, name: str = "") -> dict: + data = create(slug, name) + return { + "phast": data, + "human_url": "https://" + data["host"] + "/", + "mcp_url": "https://" + data["host"] + "/mcp", + } + + @m.tool() -def providers_search(query:str)->dict:return {"query":query,"source":"oracle","status":"adapter-next"} +def phasts_update(slug: str, name: str) -> dict: + data = mine(slug) + clean = name.strip() + if not clean: + raise ValueError("name is required") + data["name"] = clean + return write(slug, data) + + @m.tool() -def widgets_add(slug:str,widget_id:str,title:str,description:str="")->dict: - d=mine(slug);ws=d["prefab"]["home"]["widgets"] - if any(w["id"]==widget_id for w in ws):raise ValueError("widget exists") - w={"id":widget_id,"title":title,"kind":"prefab","description":description};ws.append(w);write(slug,d);return w +def providers_search(query: str) -> dict: + return {"query": query, "source": "oracle", "status": "adapter-next"} + + @m.tool() -def widgets_remove(slug:str,widget_id:str)->dict: - d=mine(slug);d["prefab"]["home"]["widgets"]=[w for w in d["prefab"]["home"]["widgets"] if w["id"]!=widget_id];write(slug,d);return {"removed":widget_id} +def widgets_list(slug: str) -> dict: + return {"widgets": list(mine(slug)["prefab"]["home"]["widgets"])} + + +@m.tool() +def widgets_add(slug: str, widget_id: str, title: str, description: str = "") -> dict: + data = mine(slug) + widgets = data["prefab"]["home"]["widgets"] + if any(widget["id"] == widget_id for widget in widgets): + raise ValueError("widget exists") + widget = {"id": widget_id, "title": title, "kind": "prefab", "description": description} + widgets.append(widget) + write(slug, data) + return widget + + +@m.tool() +def widgets_update(slug: str, widget_id: str, title: str = "", description: str = "") -> dict: + data = mine(slug) + for widget in data["prefab"]["home"]["widgets"]: + if widget["id"] == widget_id: + if title.strip(): + widget["title"] = title.strip() + if description: + widget["description"] = description + write(slug, data) + return widget + raise KeyError("unknown widget") + + +@m.tool() +def widgets_remove(slug: str, widget_id: str) -> dict: + data = mine(slug) + widgets = data["prefab"]["home"]["widgets"] + kept = [widget for widget in widgets if widget["id"] != widget_id] + if len(kept) == len(widgets): + raise KeyError("unknown widget") + data["prefab"]["home"]["widgets"] = kept + write(slug, data) + return {"removed": widget_id} + + @m.tool(app=True) -def phast_preview(slug:str)->PrefabApp:return prefab(mine(slug)) -mgmt=m.http_app(path="/mcp",transport="streamable-http",stateless_http=True);cache={} -def inst(s): - s=slug(s) - if s in cache:return cache[s] - d=read(s);x=FastMCP(d["name"],version="0.1.0",instructions="Portable personal Phast MCP.",auth=auth("https://"+d["host"]),mask_error_details=True) - @x.tool() - def phast_manifest()->dict:return read(s) - @x.tool(app=True) - def home_app()->PrefabApp:return prefab(read(s)) - cache[s]=x.http_app(path="/mcp",transport="streamable-http",stateless_http=True);return cache[s] +def phast_preview(slug: str) -> PrefabApp: + return prefab(mine(slug)) + + +mgmt = m.http_app(path="/mcp", transport="streamable-http", stateless_http=True) +cache: dict[str, Any] = {} +started: dict[str, Any] = {} +locks: dict[str, asyncio.Lock] = {} + + +def inst(value: str): + value = slug(value) + if value in cache: + return cache[value] + data = read(value) + personal = FastMCP( + data["name"], + version="0.2.0", + instructions="Portable personal Phast MCP.", + auth=auth("https://" + data["host"]), + mask_error_details=True, + ) + + @personal.tool() + def phast_status() -> dict: + owned = mine(value) + return { + "ok": True, + "slug": value, + "owner": owned["owner"], + "tools": len(owned["tools"]["installed"]), + "widgets": len(owned["prefab"]["home"]["widgets"]), + } + + @personal.tool() + def phast_manifest() -> dict: + return mine(value) + + @personal.tool(app=True) + def home_app() -> PrefabApp: + return prefab(mine(value)) + + cache[value] = personal.http_app(path="/mcp", transport="streamable-http", stateless_http=True) + return cache[value] + + +async def ready_inst(value: str): + value = slug(value) + app = inst(value) + if value in started: + return app + lock = locks.setdefault(value, asyncio.Lock()) + async with lock: + if value not in started: + cm = app.router.lifespan_context(app) + await cm.__aenter__() + started[value] = cm + return app + + class Router: - async def __call__(self,scope,receive,send): - if scope.get("type")=="lifespan": - await mgmt(scope,receive,send);return - h=dict(scope.get("headers",[])).get(b"host",b"").decode().split(":")[0].lower();p=scope.get("path","/") - if h=="my.phast.app":await mgmt(scope,receive,send);return - if h.endswith(".phast.app"): - s=h[:-10] - try:d=read(s) - except Exception:await JSONResponse({"error":"unknown Phast"},status_code=404)(scope,receive,send);return - if p=="/" and scope.get("method")=="GET":await HTMLResponse(human(d))(scope,receive,send);return - await inst(s)(scope,receive,send);return - await JSONResponse({"error":"unknown host"},status_code=404)(scope,receive,send) -app=Router() -if __name__=="__main__":uvicorn.run(app,host="::",port=int(os.getenv("PORT","8081"))) + async def __call__(self, scope, receive, send): + if scope.get("type") == "lifespan": + try: + await mgmt(scope, receive, send) + finally: + for cm in reversed(list(started.values())): + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + return + + host = dict(scope.get("headers", [])).get(b"host", b"").decode().split(":")[0].lower() + request_path = scope.get("path", "/") + method = scope.get("method", "GET") + + if host == "my.phast.app": + await mgmt(scope, receive, send) + return + + if host.endswith(".phast.app"): + value = host[:-10] + try: + data = read(value) + except Exception: + await JSONResponse({"error": "unknown Phast"}, status_code=404)(scope, receive, send) + return + + if request_path == "/" and method in {"GET", "HEAD"}: + body = human(data) + if method == "HEAD": + await Response(content=b"", media_type="text/html", status_code=200)(scope, receive, send) + else: + await HTMLResponse(body)(scope, receive, send) + return + + personal = await ready_inst(value) + await personal(scope, receive, send) + return + + await JSONResponse({"error": "unknown host"}, status_code=404)(scope, receive, send) + + +app = Router() + +if __name__ == "__main__": + uvicorn.run(app, host="::", port=int(os.getenv("PORT", "8081"))) From 545111e9e267d096decebd1e853f72473287da3e Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 04:45:11 +0000 Subject: [PATCH 20/32] Centralize personal Phast OAuth at My Phast --- server.py | 43 +++---------------------------------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/server.py b/server.py index cf3958b..4e0eace 100644 --- a/server.py +++ b/server.py @@ -47,7 +47,7 @@ class V(IntrospectionTokenVerifier): return access -def auth(base: str) -> OAuthProxy: +def auth(base: str, resource: str | None = None) -> OAuthProxy: verifier = V( introspection_url=GITEA + "/login/oauth/introspect", client_id=CID, @@ -62,7 +62,7 @@ def auth(base: str) -> OAuthProxy: upstream_client_secret=SECRET, token_verifier=verifier, base_url=base, - resource_base_url=base, + resource_base_url=resource or base, valid_scopes=["openid", "profile", "email"], forward_pkce=True, forward_resource=False, @@ -124,43 +124,6 @@ def mine(value: str) -> dict[str, Any]: return data -def _oauth_apps() -> list[dict[str, Any]]: - if not GITEA_TOKEN: - raise RuntimeError("GITEA_TOKEN is required for Phast provisioning") - with httpx.Client(timeout=10) as client: - response = client.get( - GITEA + "/api/v1/user/applications/oauth2", - headers={"Authorization": "token " + GITEA_TOKEN, "Accept": "application/json"}, - ) - response.raise_for_status() - return list(response.json() or []) - - -def ensure_oauth_callback(value: str) -> str: - value = slug(value) - callback = f"https://{value}.phast.app/auth/callback" - apps = [app for app in _oauth_apps() if str(app.get("client_id") or "") == CID] - if len(apps) != 1: - raise RuntimeError("could not uniquely resolve the shared Phast OAuth application") - app = apps[0] - redirects = list(app.get("redirect_uris") or []) - if callback in redirects: - return callback - redirects.append(callback) - with httpx.Client(timeout=10) as client: - response = client.patch( - GITEA + f"/api/v1/user/applications/oauth2/{app['id']}", - headers={"Authorization": "token " + GITEA_TOKEN, "Accept": "application/json"}, - json={ - "name": app.get("name") or "Phast", - "redirect_uris": sorted(set(redirects)), - "confidential_client": bool(app.get("confidential_client", True)), - }, - ) - response.raise_for_status() - return callback - - def create(value: str, name: str = "") -> dict[str, Any]: value = slug(value) if path(value).exists(): @@ -356,7 +319,7 @@ def inst(value: str): data["name"], version="0.2.0", instructions="Portable personal Phast MCP.", - auth=auth("https://" + data["host"]), + auth=auth("https://my.phast.app", "https://" + data["host"]), mask_error_details=True, ) From 830ab44d8ff0a8eb95fbff1bdc76d6680486daca Mon Sep 17 00:00:00 2001 From: Boring Date: Sun, 4 Oct 2026 04:46:36 +0000 Subject: [PATCH 21/32] Finish centralized Phast OAuth refactor --- server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server.py b/server.py index 4e0eace..6fee17b 100644 --- a/server.py +++ b/server.py @@ -130,7 +130,6 @@ def create(value: str, name: str = "") -> dict[str, Any]: raise ValueError("already exists") # Provision auth before durable state so a failed OAuth mutation cannot leave # a half-created personal MCP behind. - ensure_oauth_callback(value) return write( value, { From b8cd74d3d42f02362ffb0e0fe870121ab7835ac7 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:05:45 +0000 Subject: [PATCH 22/32] Make Phast landing fast and MCP-native --- index.html | 109 ++++++++++++++++++++--------------------------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/index.html b/index.html index cd2b59c..ee9e3d1 100644 --- a/index.html +++ b/index.html @@ -1,73 +1,46 @@ - - - - - phast - - - - + + + + +phast + + + + -
-
- phast. - -
-
-
- -

make something.
phast.

-
-
- Build your Phast -

Connect your tools once. Bring your tool belt anywhere.

-
-
-
ready to makeidea → app
-
-
copied
- - - \ No newline at end of file +
+
PHAST.
+
+
+

MCP APPS // ZERO TO USEFUL

+

PHAST.MAKE SOMETHING.

+
+
01 CONNECTadd my.phast.app/mcp
+
02 MAKEcreate your phast
+
03 TUNEtools + prefab widgets
+
04 RUNuse it from any agent
+
+
+
+

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast, same tools, same home.

+BUILD YOUR PHAST→ +

one connector // many personal MCPs

+
+
+
idea → mcp → appmake it // use it // keep moving
+
+
MCP COPIED
+ + \ No newline at end of file From b7e4e4328381a3cc3ddc0835bfa8693a156f7c28 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:06:17 +0000 Subject: [PATCH 23/32] Make My Phast onboarding fast and agent-first --- my/index.html | 59 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/my/index.html b/my/index.html index 5d9daec..ad3bc72 100644 --- a/my/index.html +++ b/my/index.html @@ -1,13 +1,46 @@ -My Phast
phast.
-

Your tool belt.

Connect your tools once. Bring them anywhere.
One MCP for ChatGPT, Claude, Codex, or whatever comes next. Your home is the human side of the same composition.

-

My Home

-
PHAST · TOOL BELT

Ready to connect

Add a provider and its tools become available through this Phast MCP. Credentials stay delegated to Persimmons.

0 CONNECTED PROVIDERS
ORACLE

Provider search

Discover MCP providers, OAuth routes and capabilities from the machine graph.

DISCOVERY
-
human: my.phast.appagent: /mcp
- - \ No newline at end of file + + + + +My Phast + + + + + +
+
PHAST.MY PHAST // CONTROL MCP
+
+

START HERE // ONE CONNECTION

YOUR MCP.BUILT PHAST.

+

Add My Phast to your agent once. Then create personal MCPs, add tools, stream Prefab, and keep what works.

https://my.phast.app/mcp
+
+
+

ZERO → PERSONAL MCP

CHAT IS THE SETUP UI
+
+
01 // CONNECT

ADD MY PHAST

Connect the management MCP in ChatGPT, Claude, Codex, or another MCP client. Authenticate once with Teahub.

MCP my.phast.app/mcp
+
02 // CREATE

TELL YOUR AGENT

No project wizard. Name it in chat. Wildcard DNS, TLS, home, and MCP are already waiting.

YOU create me a phast called chai
+
03 // TUNE

BUILD THE BELT

Add providers, tools, and Prefab widgets. Your agent and your web home edit the same Phast.

YOU add github + a PR widget
+
+
+
+
WHAT YOU GET

YOURNAME.PHAST.APP

A fast personal web home for the human side of your composition. Widgets are saved views of the same capabilities your agent can use.

/ human homePrefab widgetsedit by chat
+
TAKE IT ANYWHERE

/MCP

Your portable tool belt. Connect yourname.phast.app/mcp to another agent without recollecting every tool.

ChatGPTClaudeCodexwhatever's next
+
+
connect once // compose continuouslymy.phast.app/mcp → *.phast.app/mcp
+
+
MCP COPIED
+ + \ No newline at end of file From 04776116202d7ef63186758ccfddd5d8e42d1c9a Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:25:01 +0000 Subject: [PATCH 24/32] Tighten Phast app landing copy --- index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index ee9e3d1..224cda2 100644 --- a/index.html +++ b/index.html @@ -19,11 +19,11 @@
-
PHAST.
+
phast.app

MCP APPS // ZERO TO USEFUL

-

PHAST.MAKE SOMETHING.

+

MAKE SOMETHINGPHAST

01 CONNECTadd my.phast.app/mcp
02 MAKEcreate your phast
@@ -32,8 +32,8 @@
-

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast, same tools, same home.

-BUILD YOUR PHAST→ +

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast app, same tools, same home.

+BUILD YOUR PHAST APP→

one connector // many personal MCPs

From aaa5ab946637fe406544212d3f7cb6133ee38a9c Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:32:29 +0000 Subject: [PATCH 25/32] Rebalance Phast landing headline --- index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 224cda2..c433dc3 100644 --- a/index.html +++ b/index.html @@ -13,8 +13,8 @@ :root{--bg:#050505;--panel:#0d0d0d;--ink:#f5f2e9;--muted:#8a8a84;--orange:#ff5a00;--line:#272727;--pad:clamp(22px,4vw,56px)} *{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;overflow-x:hidden}a,button{color:inherit;font:inherit}a{text-decoration:none}.shell{min-height:100svh;padding:var(--pad);display:grid;grid-template-rows:auto 1fr auto;position:relative;isolation:isolate}.shell:before{content:"";position:absolute;inset:0;z-index:-2;background:repeating-linear-gradient(115deg,transparent 0 92px,rgba(255,90,0,.05) 92px 94px,transparent 94px 180px)}.shell:after{content:"";position:absolute;width:58vw;height:12px;background:var(--orange);right:-14vw;top:23%;transform:skewX(-28deg) rotate(-8deg);box-shadow:0 24px 0 rgba(255,90,0,.42),0 48px 0 rgba(255,90,0,.18);z-index:-1} .top,.foot{display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-family:Bangers,sans-serif;font-size:32px;letter-spacing:.05em;color:var(--orange)}.connector{font:11px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:none;border:0;border-bottom:1px solid #333;padding:0 0 5px;cursor:pointer}.connector:hover{color:var(--orange);border-color:var(--orange)} -.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-size:clamp(82px,15vw,220px);font-weight:400;line-height:.72;letter-spacing:.01em;margin:0;color:var(--orange);text-shadow:6px 6px 0 #1b1b1b;transform:skewY(-2deg)}.hero h1 span{display:block;color:var(--ink);font-size:.48em;line-height:.85;margin-top:.12em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} -@media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.hero h1{font-size:clamp(88px,24vw,150px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.sequence{grid-template-columns:1fr}.connector{font-size:9px}} +.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(66px,8.4vw,132px);line-height:.78;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(58px,6.4vw,102px);line-height:.78;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} +@media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.make-line{font-size:clamp(62px,15vw,112px)}.phast-line{font-size:clamp(54px,11vw,86px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.make-line{white-space:normal}.sequence{grid-template-columns:1fr}.connector{font-size:9px}} @@ -23,7 +23,7 @@

MCP APPS // ZERO TO USEFUL

-

MAKE SOMETHINGPHAST

+

MAKE SOMETHINGPHAST

01 CONNECTadd my.phast.app/mcp
02 MAKEcreate your phast
From fe5a7bbf7147b1cae3682af9ba477d42127e7a15 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:34:49 +0000 Subject: [PATCH 26/32] Make Phast the hero beneath eyebrow --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index c433dc3..0712bb6 100644 --- a/index.html +++ b/index.html @@ -13,8 +13,8 @@ :root{--bg:#050505;--panel:#0d0d0d;--ink:#f5f2e9;--muted:#8a8a84;--orange:#ff5a00;--line:#272727;--pad:clamp(22px,4vw,56px)} *{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;overflow-x:hidden}a,button{color:inherit;font:inherit}a{text-decoration:none}.shell{min-height:100svh;padding:var(--pad);display:grid;grid-template-rows:auto 1fr auto;position:relative;isolation:isolate}.shell:before{content:"";position:absolute;inset:0;z-index:-2;background:repeating-linear-gradient(115deg,transparent 0 92px,rgba(255,90,0,.05) 92px 94px,transparent 94px 180px)}.shell:after{content:"";position:absolute;width:58vw;height:12px;background:var(--orange);right:-14vw;top:23%;transform:skewX(-28deg) rotate(-8deg);box-shadow:0 24px 0 rgba(255,90,0,.42),0 48px 0 rgba(255,90,0,.18);z-index:-1} .top,.foot{display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-family:Bangers,sans-serif;font-size:32px;letter-spacing:.05em;color:var(--orange)}.connector{font:11px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:none;border:0;border-bottom:1px solid #333;padding:0 0 5px;cursor:pointer}.connector:hover{color:var(--orange);border-color:var(--orange)} -.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(66px,8.4vw,132px);line-height:.78;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(58px,6.4vw,102px);line-height:.78;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} -@media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.make-line{font-size:clamp(62px,15vw,112px)}.phast-line{font-size:clamp(54px,11vw,86px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.make-line{white-space:normal}.sequence{grid-template-columns:1fr}.connector{font-size:9px}} +.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(54px,6.5vw,102px);line-height:.8;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(112px,13vw,204px);line-height:.72;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} +@media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.make-line{font-size:clamp(46px,11vw,82px)}.phast-line{font-size:clamp(92px,22vw,154px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.make-line{white-space:normal}.sequence{grid-template-columns:1fr}.connector{font-size:9px}} From 6a9bdf450c237990af66463d9d2781ef332ff0d9 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 06:40:09 +0000 Subject: [PATCH 27/32] Widen Phast hero lockup --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 0712bb6..3705e91 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ :root{--bg:#050505;--panel:#0d0d0d;--ink:#f5f2e9;--muted:#8a8a84;--orange:#ff5a00;--line:#272727;--pad:clamp(22px,4vw,56px)} *{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;overflow-x:hidden}a,button{color:inherit;font:inherit}a{text-decoration:none}.shell{min-height:100svh;padding:var(--pad);display:grid;grid-template-rows:auto 1fr auto;position:relative;isolation:isolate}.shell:before{content:"";position:absolute;inset:0;z-index:-2;background:repeating-linear-gradient(115deg,transparent 0 92px,rgba(255,90,0,.05) 92px 94px,transparent 94px 180px)}.shell:after{content:"";position:absolute;width:58vw;height:12px;background:var(--orange);right:-14vw;top:23%;transform:skewX(-28deg) rotate(-8deg);box-shadow:0 24px 0 rgba(255,90,0,.42),0 48px 0 rgba(255,90,0,.18);z-index:-1} .top,.foot{display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-family:Bangers,sans-serif;font-size:32px;letter-spacing:.05em;color:var(--orange)}.connector{font:11px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:none;border:0;border-bottom:1px solid #333;padding:0 0 5px;cursor:pointer}.connector:hover{color:var(--orange);border-color:var(--orange)} -.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(54px,6.5vw,102px);line-height:.8;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(112px,13vw,204px);line-height:.72;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} +.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(54px,6.5vw,102px);line-height:.8;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(112px,16vw,250px);line-height:.72;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)} @media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.make-line{font-size:clamp(46px,11vw,82px)}.phast-line{font-size:clamp(92px,22vw,154px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.make-line{white-space:normal}.sequence{grid-template-columns:1fr}.connector{font-size:9px}} From 86a178fd8db0f38dd5b5b84606426cfa735efa90 Mon Sep 17 00:00:00 2001 From: matcha Date: Wed, 7 Oct 2026 19:44:20 +0000 Subject: [PATCH 28/32] Align Phast product copy --- index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 3705e91..eca8d60 100644 --- a/index.html +++ b/index.html @@ -26,14 +26,14 @@

MAKE SOMETHINGPHAST

01 CONNECTadd my.phast.app/mcp
-
02 MAKEcreate your phast
+
02 MAKEcreate your Phast
03 TUNEtools + prefab widgets
04 RUNuse it from any agent
-

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast app, same tools, same home.

-BUILD YOUR PHAST APP→ +

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast, same tools, same home.

+BUILD YOUR PHAST→

one connector // many personal MCPs

From 931ac1112cdb037c1ae52435083fb1efb7eb7fb1 Mon Sep 17 00:00:00 2001 From: matcha Date: Wed, 7 Oct 2026 19:51:48 +0000 Subject: [PATCH 29/32] Use placeholder name in Phast creation example --- my/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/my/index.html b/my/index.html index ad3bc72..e55c3c5 100644 --- a/my/index.html +++ b/my/index.html @@ -29,7 +29,7 @@

ZERO → PERSONAL MCP

CHAT IS THE SETUP UI
01 // CONNECT

ADD MY PHAST

Connect the management MCP in ChatGPT, Claude, Codex, or another MCP client. Authenticate once with Teahub.

MCP my.phast.app/mcp
-
02 // CREATE

TELL YOUR AGENT

No project wizard. Name it in chat. Wildcard DNS, TLS, home, and MCP are already waiting.

YOU create me a phast called chai
+
02 // CREATE

TELL YOUR AGENT

No project wizard. Name it in chat. Wildcard DNS, TLS, home, and MCP are already waiting.

YOU create me a phast called sunday
03 // TUNE

BUILD THE BELT

Add providers, tools, and Prefab widgets. Your agent and your web home edit the same Phast.

YOU add github + a PR widget
From 7b9172568c63aa201f30992008866889cb9ae060 Mon Sep 17 00:00:00 2001 From: Tea Worker Date: Wed, 7 Oct 2026 13:17:56 -0700 Subject: [PATCH 30/32] Rewrite Phast app hero CTA --- my/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/my/index.html b/my/index.html index e55c3c5..d954dfb 100644 --- a/my/index.html +++ b/my/index.html @@ -23,7 +23,7 @@
PHAST.MY PHAST // CONTROL MCP

START HERE // ONE CONNECTION

YOUR MCP.BUILT PHAST.

-

Add My Phast to your agent once. Then create personal MCPs, add tools, stream Prefab, and keep what works.

https://my.phast.app/mcp
+

Add My Phast to your agent once. Then create personal MCPs, add tools, stream Prefab, and keep what works.

https://my.phast.app/mcp

ZERO → PERSONAL MCP

CHAT IS THE SETUP UI
From 1f15789abd32c3ea22b04e5c3de022890b936665 Mon Sep 17 00:00:00 2001 From: Tea Worker Date: Wed, 7 Oct 2026 17:36:12 -0700 Subject: [PATCH 31/32] codex job: run 3455 Receipt: https://teahub.xyz/matcha/teahub/actions/runs/3533 --- my/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/my/index.html b/my/index.html index d954dfb..36d3362 100644 --- a/my/index.html +++ b/my/index.html @@ -2,7 +2,7 @@ -My Phast +my.phast.app — your Phast app " - + "
phast." + + "
Phast app." + data["host"] + "/mcp

" + data["name"] @@ -198,9 +198,9 @@ def human(data: dict[str, Any]) -> str: m = FastMCP( - "My Phast", + "My Phast app", version="0.2.0", - instructions="Create and manage portable Phast MCP tool belts and Prefab homes.", + instructions="Create and manage portable Phast app MCP tool belts and Prefab homes.", auth=auth("https://my.phast.app"), mask_error_details=True, ) @@ -317,7 +317,7 @@ def inst(value: str): personal = FastMCP( data["name"], version="0.2.0", - instructions="Portable personal Phast MCP.", + instructions="Portable personal Phast app MCP.", auth=auth("https://my.phast.app", "https://" + data["host"]), mask_error_details=True, ) @@ -385,7 +385,7 @@ class Router: try: data = read(value) except Exception: - await JSONResponse({"error": "unknown Phast"}, status_code=404)(scope, receive, send) + await JSONResponse({"error": "unknown Phast app"}, status_code=404)(scope, receive, send) return if request_path == "/" and method in {"GET", "HEAD"}: