Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web/node_modules/
web/dist/
26 changes: 26 additions & 0 deletions packages/agentenv-protocol/examples/website/notes/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ARG NOTES=packages/agentenv-protocol/examples/website/notes

FROM mirror.gcr.io/library/node:22-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS web
ARG NOTES
WORKDIR /web
COPY ${NOTES}/web/package.json ${NOTES}/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY ${NOTES}/web/ ./
RUN npm run build

FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
ARG NOTES
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY packages/agentenv-protocol /tmp/agentenv-protocol
RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol
COPY ${NOTES}/site.py /app/site.py
COPY ${NOTES}/data /app/data
COPY --from=web /web/dist /app/web/dist
ENV PORT=8000
EXPOSE 8000
RUN useradd --create-home --uid 10001 appuser
USER appuser
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)"
CMD ["python", "site.py"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/node_modules
**/dist
**/__pycache__
**/.pytest_cache
**/.venv
.git
87 changes: 87 additions & 0 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Notes: a reference website/v1 site

Notes is a small notes app built on `agentenv_protocol.website.WebsiteEnvironment`. It is the reference site for the
`urn:agentenv:website/v1` server contract. Each user keeps their own notes and can create, edit, mark done and delete
them. The page shows the universe's time.

| File | What it is |
| --- | --- |
| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). |
| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. |
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. |
| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. |
| `env.toml` | The website env as a bundle entry. |

## What it exercises

- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`.
The card advertises website/v1 with the domains, the health path and every route by name.
- **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`,
`edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`.
- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only when the gateway
stamps `AgentEnv-Role: admin`; any other role gets 403.
- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the
store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded
user. A user's notes are invisible to every other user, and another user's note answers 404.
- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or
as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns
`{initial_state, current_state, state_diff}`.
- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names
it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header.
It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the
SDK.
- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a
`data/reset` and before the next `data/add`.

## Run it

From this folder:

```bash
cd web && npm ci && npm run build && cd ..
PYTHONPATH=<repo>/packages/agentenv-protocol/src python site.py
```

Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies
`/api` to port 8000.

## Test it

```bash
python -m pytest packages/agentenv-protocol/tests/test_website_notes_example.py
```

The test imports `site.py`, runs the conformance checker, and checks sign-in, per-user notes, the admin-only route,
clock-synced timestamps, and the data plane's reset and seed load. It serves a stand-in page, so it needs no Vite
build.

## Build and verify the image

The build context is the repo root, because the image installs the protocol package from this repo's source:

```bash
docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site .
docker run -d --name notes-site -p 18080:8000 notes-site
python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal
curl -s http://localhost:18080/api/notes
```

Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a
clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart
the container before using it.

## Register it

```bash
agent-env env website put --id notes-site \
--server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \
--server-docker-context . --health-path /healthz
```

Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env
has a server image and no frontend image. Pass `--skip-validation` to register without deploying.

`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and
`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this
folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a
published release instead, register the site with `put` as above.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"current_user": "ann",
"users": [
{"id": "ann", "name": "Ann Lee"},
{"id": "ben", "name": "Ben Ortiz"}
],
"notes": [
{"id": 1, "owner": "ann", "title": "Buy milk", "body": "Oat, two cartons.", "done": false,
"created_at": "2026-01-05T09:00:00.000Z", "updated_at": "2026-01-05T09:00:00.000Z"},
{"id": 2, "owner": "ann", "title": "Book dentist", "body": "", "done": true,
"created_at": "2026-01-04T15:30:00.000Z", "updated_at": "2026-01-06T08:10:00.000Z"},
{"id": 3, "owner": "ben", "title": "Draft the quarterly report", "body": "Due Friday.", "done": false,
"created_at": "2026-01-03T11:00:00.000Z", "updated_at": "2026-01-03T11:00:00.000Z"}
]
}
4 changes: 4 additions & 0 deletions packages/agentenv-protocol/examples/website/notes/env.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "website"
environment_name = "notes"
health = "/healthz"
backend_image = { dockerfile = "Dockerfile" }
131 changes: 131 additions & 0 deletions packages/agentenv-protocol/examples/website/notes/site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps
notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's
``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data
plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's
``AgentEnv-Role`` names them, can list every user's notes.

Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000)."""
from __future__ import annotations

from typing import Optional

from pydantic import BaseModel

from agentenv_protocol import environment_card
from agentenv_protocol.website import (
ApiContext,
BadRequest,
NotFound,
Unauthorized,
WebsiteEnvironment,
api,
ready_check,
website,
)
from agentenv_protocol.website.clock import iso

NOTES = "notes"
USERS = "users"


class NewNote(BaseModel):
title: str
body: str = ""


class NoteEdit(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
done: Optional[bool] = None


class SignIn(BaseModel):
user: str


@environment_card(name="notes")
@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")
class Notes(WebsiteEnvironment):
@ready_check
def seeded(self) -> bool:
"""The site is usable once its store knows who may sign in."""
return bool(self.store[USERS])

@api.get("/api/users", name="list_users")
def list_users(self) -> list:
"""The users a browser can sign in as."""
return self.store[USERS]

@api.post("/api/sign-in", name="sign_in")
def sign_in(self, body: SignIn, ctx: ApiContext) -> dict:
"""Sign the browser in as one of the site's users."""
if self.store.get(USERS, body.user) is None:
raise NotFound(f"no user {body.user!r}")
ctx.sign_in(body.user)
return {"user": ctx.user}

@api.get("/api/notes", name="list_notes")
def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list:
"""The signed-in user's notes, newest first; with ``done``, only those done or not."""
match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done}
return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True)

@api.post("/api/notes", name="create_note", status=201)
def create_note(self, note: NewNote, ctx: ApiContext) -> dict:
"""Add a note for the signed-in user, not done, created and updated now."""
now = iso(self.now())
return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body,
"done": False, "created_at": now, "updated_at": now})

@api.get("/api/notes/{note_id:int}", name="get_note")
def get_note(self, note_id: int, ctx: ApiContext) -> dict:
return self._note(note_id, ctx)

@api.patch("/api/notes/{note_id:int}", name="edit_note")
def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict:
"""Change a note's title, body or done, and its ``updated_at``."""
note = self._note(note_id, ctx)
fields = edit.model_dump(exclude_none=True)
if "title" in fields:
fields["title"] = _title(fields["title"])
note.update(fields, updated_at=iso(self.now()))
return note

@api.post("/api/notes/{note_id:int}/toggle", name="toggle_note")
def toggle_note(self, note_id: int, ctx: ApiContext) -> dict:
"""Mark a note done, or not done when it was."""
note = self._note(note_id, ctx)
note.update(done=not note["done"], updated_at=iso(self.now()))
return note

@api.delete("/api/notes/{note_id:int}", name="delete_note")
def delete_note(self, note_id: int, ctx: ApiContext) -> dict:
self.store.delete(NOTES, self._note(note_id, ctx)["id"])
return {"deleted": note_id}

@api.get("/api/admin/notes", name="all_notes", roles=["admin"])
def all_notes(self) -> list:
"""Every user's notes, for an ``admin`` caller only."""
return self.store[NOTES]

def _user(self, ctx: ApiContext) -> str:
if ctx.user is None:
raise Unauthorized("sign in first")
return ctx.user

def _note(self, note_id: int, ctx: ApiContext) -> dict:
"""The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist."""
note = self.store.get(NOTES, note_id)
if note is None or note["owner"] != self._user(ctx):
raise NotFound(f"no note {note_id}")
return note


def _title(title: str) -> str:
if not title.strip():
raise BadRequest("a note needs a title", code="empty_title")
return title.strip()


if __name__ == "__main__":
Notes().serve()
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Notes</title>
</head>
<body>
<header class="bar">
<h1>Notes</h1>
<div class="who">
<label>Signed in as
<select id="user" aria-label="Signed-in user"></select>
</label>
<time id="universe-now" title="The universe's time, from X-Universe-Now"></time>
</div>
</header>
<main>
<form id="new-note" class="card">
<input id="new-title" name="title" placeholder="New note title" autocomplete="off" required />
<textarea id="new-body" name="body" placeholder="Details (optional)" rows="2"></textarea>
<button type="submit">Add note</button>
</form>
<nav class="filters" aria-label="Filter notes">
<button data-filter="all" class="active">All</button>
<button data-filter="open">Open</button>
<button data-filter="done">Done</button>
</nav>
<p id="error" role="alert" hidden></p>
<ul id="notes"></ul>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Loading
Loading