Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
80c0abd
Add private visibility, unified signup, and private-by-default pages
simonbc Apr 13, 2026
7067449
Add homepage repo story, INDEX.md profile, and visibility badges
simonbc Apr 13, 2026
5b2a419
Fix lint warning, update homepage copy to matter-of-fact tone
simonbc Apr 13, 2026
92a19ee
Remove unused index_page variable
simonbc Apr 13, 2026
b6d0dcb
Move logo to top-left header bar on homepage
simonbc Apr 13, 2026
ad9ed81
Improve landing page design: centered layout, combined input+button, …
simonbc Apr 13, 2026
7835140
Use pill radius on signup form to match existing button style
simonbc Apr 13, 2026
3598ca7
Fix sign-in link positioning in homepage header
simonbc Apr 13, 2026
77b3821
Green signup button, larger header logo
simonbc Apr 13, 2026
91c02d6
Add underline to create-a-page link
simonbc Apr 13, 2026
4221b52
Use arrow instead of underline for create-a-page link
simonbc Apr 13, 2026
c4f0da9
Add empty-state profile with getting-started checklist and MCP config…
simonbc Apr 13, 2026
8ec217a
Remove unused username variable in MCP config endpoint
simonbc Apr 13, 2026
4d0f462
Add server-side event logging for signup, signin, page creation, and …
simonbc Apr 13, 2026
fea9846
Improve verify and username pages: add logo, show email, add resend hint
simonbc Apr 13, 2026
a49741c
Move verify/setup logo to top-left corner
simonbc Apr 13, 2026
4ac7eee
Use landing page header on verify and username pages
simonbc Apr 13, 2026
27ab2b2
Remove max-width on homepage header for full-width layout
simonbc Apr 13, 2026
739065d
Fix header on verify/setup pages: position absolute to span full width
simonbc Apr 13, 2026
a3b019e
Remove unused verify-logo CSS
simonbc Apr 13, 2026
c1df125
Restore sidebar on empty profile, add links section, fix checklist co…
simonbc Apr 13, 2026
b9bd0fb
Redesign empty profile: GitHub-style quickstart with code blocks
simonbc Apr 13, 2026
69b2ae6
Replace floating profile header with integrated welcome on empty profile
simonbc Apr 13, 2026
60a18a6
Remove links from sidebar, hide sidebar on empty profile
simonbc Apr 13, 2026
f6799fa
Use agent-agnostic language for MCP setup
simonbc Apr 13, 2026
b8850fe
Use curl install command for CLI setup
simonbc Apr 13, 2026
1478540
Add base URL to MCP config, remove API section from quickstart
simonbc Apr 13, 2026
8c33052
Add darker background to site header
simonbc Apr 13, 2026
14a3f44
Darken site header background
simonbc Apr 13, 2026
4553982
Tune site header to darker shade between bg and black
simonbc Apr 13, 2026
86aa87d
Fix MCP config block showing empty: use CSS class instead of inline s…
simonbc Apr 13, 2026
cfa4089
Hide comments on private pages
simonbc Apr 13, 2026
62e056d
Fix: all new pages by signed-in users default to private, not just su…
simonbc Apr 13, 2026
134eb9f
Lighten site header background
simonbc Apr 13, 2026
07bbc2a
Add profile setup step to signup flow, more profile header space, mat…
simonbc Apr 13, 2026
e27128d
Replace bio with avatar upload in signup profile step
simonbc Apr 13, 2026
271701e
Redesign profile setup: horizontal card layout mirroring the actual p…
simonbc Apr 13, 2026
436ff34
Make avatar placeholder clickable to trigger file picker
simonbc Apr 13, 2026
7c603d1
Left-align username display in profile setup card
simonbc Apr 13, 2026
a0b5e2d
Fix avatar preview image display in profile setup
simonbc Apr 13, 2026
03a4f27
Fix avatar click: use div with onclick instead of label to avoid even…
simonbc Apr 13, 2026
0472814
Fix CSP: move inline scripts to external files, hide file input with …
simonbc Apr 13, 2026
6583d51
Fix avatar preview: use blob URL instead of data URL for CSP compatib…
simonbc Apr 13, 2026
98e5199
Use createElement for avatar preview to avoid innerHTML CSP issues
simonbc Apr 13, 2026
6db935b
Allow blob: URLs in CSP img-src for avatar preview
simonbc Apr 13, 2026
0a62240
Redirect signed-in users from / to their profile
simonbc Apr 13, 2026
f44a7f9
Add Connect Agent settings page, sidebar CTA, fix MCP page styling
simonbc Apr 13, 2026
4b84649
Add edit profile hint in sidebar when bio/avatar missing
simonbc Apr 13, 2026
d0b5afa
Add Edit profile link to profile header
simonbc Apr 13, 2026
e4ae902
Unify action-link styling, remove sidebar profile hint
simonbc Apr 13, 2026
0f2a070
Move edit profile to right side, align profile header with content width
simonbc Apr 13, 2026
214a18d
Revert profile header width, remove About heading from sidebar, reord…
simonbc Apr 13, 2026
b268b17
Add bot icon next to Connect an agent link
simonbc Apr 13, 2026
9d5f5ad
Fix sidebar link padding, move bot icon to AGENTS.md
simonbc Apr 13, 2026
f8d5d54
Make AGENTS.md sidebar link smaller and muted to distinguish from pages
simonbc Apr 13, 2026
729a4e3
Display AGENTS.md as sidebar metadata, not a page link
simonbc Apr 13, 2026
46a31be
Increase spacing above AGENTS.md meta section
simonbc Apr 13, 2026
f6b000e
Constrain profile header to content width so Edit profile aligns with…
simonbc Apr 13, 2026
d8cc222
Move Edit profile inline next to username
simonbc Apr 13, 2026
4d96dd5
Redesign Connect Agent page: step-based layout, no card wrapper
simonbc Apr 13, 2026
463371b
Redesign Connect Agent page: Claude Code OAuth, Claude Desktop config…
simonbc Apr 13, 2026
6890025
Redesign Connect Agent: API token first, platform shortcuts below
simonbc Apr 13, 2026
2fea470
Simplify Connect Agent to copy-paste prompt, fix code block overflow
simonbc Apr 13, 2026
bb0bcdb
Fix Connect Agent design: prose prompt in card, copy button, proper w…
simonbc Apr 13, 2026
61edeac
Add /api/v1/agent-setup endpoint, simplify setup prompt to one fetch
simonbc Apr 13, 2026
16b83a8
Remove unused username variable
simonbc Apr 13, 2026
8c28436
Redesign Connect Agent page: use panel card, left-aligned, cleaner st…
simonbc Apr 13, 2026
52b85db
Add below-fold sections to landing page: how it works, agent angle, b…
simonbc Apr 13, 2026
5ebbc72
Show landing page for all users, avatar in header when signed in
simonbc Apr 13, 2026
f2487d5
fix: block private page revision access, rotate agent tokens
simonbc Apr 13, 2026
e47e347
docs: add TODOS.md with MCP tool descriptions follow-up
simonbc Apr 13, 2026
a93dccd
fix: pass user_id to delete_api_token in setup prompt rotation
simonbc Apr 13, 2026
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
7 changes: 7 additions & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# TODOS

## Add philosophy/workflow guidance to MCP tool descriptions
**What:** Enhance MCP tool descriptions in `routes/mcp.py` to include behavioral guidance (when to create pages, visibility recommendations, titling conventions) alongside the current functional descriptions.
**Why:** Claude Code (the primary agent path) reads MCP tool descriptions, not `/api/v1/agent-setup`. This is where philosophy guidance has the highest impact on actual agent behavior. Codex review flagged this as higher leverage than the agent-setup endpoint changes.
**Context:** Currently MCP tool descriptions are functional only (e.g., "Create a new page. Content should be markdown starting with `# Title`."). Philosophy guidance lives in `agent-setup` (for the copy-paste prompt path) but not in MCP (for the native Claude Code path). The `list_pages` MCP tool already returns conventions with AGENTS content, but the tool descriptions themselves don't prime the agent's behavior.
**Depends on:** Nothing. Can be done independently of the agent-setup philosophy changes.
2 changes: 1 addition & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def make_session_permanent():
_CSP = (
"default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;"
" connect-src 'self' https://cloudflareinsights.com; style-src 'self';"
" img-src 'self' https://*.fly.storage.tigris.dev"
" img-src 'self' blob: https://*.fly.storage.tigris.dev"
)


Expand Down
4 changes: 2 additions & 2 deletions cli/jottit_cli/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def _read_content(file, title):
@click.option("--slug", help="Custom slug for the page")
@click.option(
"--visibility",
type=click.Choice(["unlisted", "listed", "pinned"]),
type=click.Choice(["private", "unlisted", "listed", "pinned"]),
default=None,
help="Page visibility (default: unlisted)",
help="Page visibility (default: private)",
)
@click.option("--title", help="Page title (prepended as # heading if missing)")
@click.option(
Expand Down
48 changes: 48 additions & 0 deletions db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import importlib.util
import json
import os
import secrets
import threading
Expand Down Expand Up @@ -577,6 +578,53 @@ def create_api_token(user_id, name):
return token, row["id"]


def get_or_create_api_token(user_id, name):
"""Return an existing token by name, or create one. Returns (token_str, token_id).
token_str is None if the token already existed (value not stored)."""
with get_db() as conn:
existing = conn.execute(
"SELECT id FROM api_tokens WHERE user_id = %s AND name = %s LIMIT 1",
(user_id, name),
).fetchone()
if existing:
return None, existing["id"]
return create_api_token(user_id, name)


def get_setup_checklist(user_id):
"""Return checklist status for the getting-started flow."""
with get_db() as conn:
page_count = conn.execute(
"SELECT COUNT(*) AS c FROM pages WHERE user_id = %s",
(user_id,),
).fetchone()["c"]
token_count = conn.execute(
"SELECT COUNT(*) AS c FROM api_tokens WHERE user_id = %s",
(user_id,),
).fetchone()["c"]
mcp_used = conn.execute(
"SELECT EXISTS(SELECT 1 FROM revisions r JOIN pages p ON r.page_id = p.id WHERE p.user_id = %s AND r.source = 'mcp') AS e",
(user_id,),
).fetchone()["e"]
return {
"has_pages": page_count > 0,
"has_token": token_count > 0,
"has_mcp": bool(mcp_used),
}


def log_event(user_id, event, metadata=None):
try:
with get_db() as conn:
conn.execute(
"INSERT INTO user_events (user_id, event, metadata) VALUES (%s, %s, %s)",
(user_id, event, json.dumps(metadata) if metadata else None),
)
conn.commit()
except Exception:
pass


def get_api_tokens(user_id):
with get_db() as conn:
return conn.execute(
Expand Down
4 changes: 4 additions & 0 deletions migrations/026_add_private_visibility.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Re-add 'private' as a valid visibility state.
-- Private pages are only visible to their owner.
-- Lifecycle: private → unlisted → listed → pinned
-- (Previously removed in migration 018.)
10 changes: 10 additions & 0 deletions migrations/027_add_user_events.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS user_events (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
event TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id);
CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC);
11 changes: 10 additions & 1 deletion routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
}

VISIBILITY_OPTIONS = ("unlisted", "listed", "pinned")
VISIBILITY_OPTIONS = ("private", "unlisted", "listed", "pinned")


def _get_subdomain():
Expand Down Expand Up @@ -106,6 +106,15 @@ def can_edit(page_meta):
return is_creator(page_meta)


def check_page_visibility(page_meta):
"""Abort 404 if page is private and current user is not the owner."""
if not page_meta:
return
if page_meta.get("visibility") == "private":
if session.get("user_id") != page_meta["user_id"]:
abort(404)


def send_verification(email, purpose):
code = create_verification_code(email, purpose)
send_verification_email(email, code)
Expand Down
123 changes: 118 additions & 5 deletions routes/admin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import re

from flask import flash, redirect, render_template, request, session
from flask import flash, jsonify, redirect, render_template, request, session

from db import (
check_username_available,
create_api_token,
delete_api_token,
delete_user,
find_or_create_user,
get_or_create_api_token,
log_event,
set_user_username,
get_api_tokens,
get_pages_for_user,
get_user,
Expand Down Expand Up @@ -50,9 +53,6 @@ def signin():
"signin.html", error="Please enter a valid email address."
)

if not user_exists(email):
return render_template("signin.html", not_found=True)

send_verification(email, "signin")
return redirect("/signin/verify")

Expand Down Expand Up @@ -83,8 +83,10 @@ def signin_verify():
session["user_id"] = user_id
user = get_user(user_id)
if user and user.get("username"):
log_event(user_id, "signin")
return redirect(profile_url(user["username"]))
return redirect("/")
log_event(user_id, "signup")
return redirect("/setup/username")


@bp.route("/signout", methods=["POST"])
Expand All @@ -93,6 +95,108 @@ def signout():
return redirect("/")


@bp.route("/setup/username", methods=["GET", "POST"])
def setup_username():
user_id = session.get("user_id")
if not user_id:
return redirect("/signin")
user = get_user(user_id)
if user and user.get("username"):
return redirect(profile_url(user["username"]))

if request.method == "GET":
return render_template("setup_username.html")

username = request.form.get("username", "").strip().lower()
if not username:
return render_template("setup_username.html", error="Username is required.")
if not valid_username(username):
return render_template(
"setup_username.html",
error="Username must be lowercase letters, numbers, and hyphens only.",
username=username,
)
if username in RESERVED_USERNAMES:
return render_template(
"setup_username.html",
error="That username is reserved.",
username=username,
)
if not check_username_available(username):
return render_template(
"setup_username.html",
error="That username is already taken.",
username=username,
)

set_user_username(user_id, username)
update_user_settings(user_id, name="", username=username, bio="", license=None)
return redirect("/setup/profile")


@bp.route("/setup/profile", methods=["GET", "POST"])
def setup_profile():
user_id = session.get("user_id")
if not user_id:
return redirect("/signin")
user = get_user(user_id)
if not user or not user.get("username"):
return redirect("/setup/username")

if request.method == "GET":
return render_template("setup_profile.html", username=user["username"])

name = request.form.get("name", "").strip()
if name:
update_user_settings(
user_id, name=name, username=user["username"], bio="", license=None
)

file = request.files.get("avatar")
if file and file.filename:
error = validate_image(file)
if not error:
ext = ALLOWED_IMAGE_TYPES[file.content_type]
fmt = file.content_type.split("/")[-1].upper()
if fmt == "JPG":
fmt = "JPEG"
cropped = crop_square(file, fmt)
key = f"{user_id}/avatar.{ext}"
url = upload_image(key, cropped, file.content_type)
update_user_avatar(user_id, url)

return redirect(profile_url(user["username"]))


@bp.route("/setup/mcp-config", methods=["POST"])
def setup_mcp_config():
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Unauthorized"}), 401
user = get_user(user_id)
if not user:
return jsonify({"error": "Unauthorized"}), 401

token, token_id = get_or_create_api_token(user_id, "mcp-default")
if token is None:
# Token already exists but we can't show the stored value.
# Rotate: delete old, create new.
delete_api_token(token_id, user_id)
token, _ = create_api_token(user_id, "mcp-default")

base_url = request.url_root.rstrip("/")
prompt = (
f"You are connected to my Jottit, a place where ideas become "
f"beautiful pages on the web. When I share an insight worth keeping, "
f"capture it as a page. "
f"Fetch {base_url}/api/v1/agent-setup with the header "
f"'Authorization: Bearer {token}' for API details and my conventions. "
f"Then list my pages to understand what's already there."
)
log_event(user_id, "token_generated")
return jsonify({"config_text": prompt})


@bp.route("/pages")
def user_pages():
from routes.public import _build_page_item, sidebar_vars, compute_initials
Expand Down Expand Up @@ -362,6 +466,15 @@ def settings_delete():
return redirect("/")


@bp.route("/settings/mcp")
def settings_mcp():
user_id, user = require_user()
if not user:
return redirect("/signin")
base_url = request.url_root.rstrip("/")
return render_template("settings_mcp.html", user=user, base_url=base_url)


@bp.route("/settings/tokens", methods=["GET", "POST"])
@limiter.limit("5 per 5 minutes", methods=["POST"])
def settings_tokens():
Expand Down
85 changes: 84 additions & 1 deletion routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,89 @@ def _serialize_page(meta, page_data):
}


@api_bp.route("/agent-setup")
def agent_setup():
user = _require_auth()
if not user:
return _error(
"Unauthorized — include your API token as: Authorization: Bearer YOUR_TOKEN",
401,
)
base_url = request.url_root.rstrip("/")
username = user.get("username", "")
pages = get_pages_for_user(user["id"])
return jsonify(
{
"welcome": f"You are connected to Jottit as @{username}.",
"philosophy": (
"Jottit turns markdown into beautiful, shareable web pages. "
"When someone shares an insight worth keeping, your job is to "
"capture it as a well-structured page. Think of yourself as a "
"writing partner, not an API client."
),
"workflow": {
"when_to_create": (
"When the user shares an insight, idea, or piece of thinking "
"that deserves to exist as its own page. Don't create pages "
"for trivial exchanges."
),
"when_to_update": (
"When the user refines or adds to an existing topic. Check "
"list_pages first to avoid duplicates."
),
"default_visibility": "private",
"titling": (
"Every page starts with a markdown H1 (# Title). Use clear, "
"descriptive titles. The title becomes the page's identity."
),
"content_style": (
"Write in clean markdown. The output will be rendered with "
"beautiful typography. No need for HTML."
),
},
"conventions": build_conventions(pages),
"about": "Jottit is a markdown publishing tool. Pages are written in markdown and get a beautiful URL.",
"api_base": f"{base_url}/api/v1",
"your_profile": f"{base_url}/@{username}",
"endpoints": {
"list_pages": {
"method": "GET",
"path": "/pages",
"description": "List all your pages. Returns slug, title, visibility, and updated_at.",
},
"create_page": {
"method": "POST",
"path": "/pages",
"description": "Create a new page.",
"body": {
"content": "# Title\\n\\nMarkdown content here.",
"slug": "(optional)",
"visibility": "(optional: private, unlisted, listed, pinned)",
},
},
"get_page": {
"method": "GET",
"path": "/pages/{slug}",
"description": "Get a page by slug. Returns title, content (markdown), visibility, and updated_at.",
},
"update_page": {
"method": "PUT",
"path": "/pages/{slug}",
"description": "Update a page. Send content and/or visibility.",
"body": {
"content": "(optional) full markdown",
"visibility": "(optional)",
},
},
},
"auth": "Include your token in every request: Authorization: Bearer YOUR_TOKEN",
"content_format": "All content is markdown. Start with '# Title' on the first line.",
"default_visibility": "New pages are private by default. Change to 'listed' to show on your profile.",
"try_it": f"To verify this works, call GET {base_url}/api/v1/pages to list your pages.",
}
)


@api_bp.route("/user")
def get_current_user():
user = _require_auth()
Expand Down Expand Up @@ -155,7 +238,7 @@ def create_page():
slug = slugify(data.get("slug", "")) or generate_slug()

if user:
visibility = data.get("visibility", "unlisted")
visibility = data.get("visibility", "private")
if visibility not in VISIBILITY_OPTIONS:
return _error(
f"Visibility must be one of: {', '.join(VISIBILITY_OPTIONS)}", 400
Expand Down
Loading