Named after Calliope — the Greek Muse of eloquence and epic poetry, eldest of the nine Muses. She was the voice that made stories worth following from beginning to end. Calliope RM does the same for your product roadmap.
Calliope RM is a customer-facing roadmap and feedback portal that runs entirely on GitHub Issues + Labels: customers submit issues, see what's planned, in progress, and shipped, and your team works in GitHub like normal.
No database. No auth system. No admin panel. The portal reads and writes through a thin proxy — drop it in front of any repo and you have a public roadmap in an afternoon.
- How it works
- Deployment options
- The submission form
- Image uploads
- Submitter PII handling
- Setup — Cloudflare Worker
- Setup — VPS with nginx + PHP-FPM
- Label reference
- Triage workflow
- Customization
- Security notes
- Troubleshooting
- License
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Customer │ HTTPS │ Backend proxy │ API │ GitHub Issues │
│ (browser) │ ──────▶ │ (Worker or PHP) │ ──────▶ │ (private repo) │
│ index.html │ ◀────── │ │ ◀────── │ │
└──────────────┘ └──────────────────┘ └──────────────────┘
▲ │
│ token never │ holds GITHUB_TOKEN
│ leaves server │ as encrypted env / secret
Two endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
/api/submit |
POST | Customer submits a new issue (multipart, supports image uploads) |
/api/board |
GET | Returns issues with the public label, grouped into kanban columns |
/api/comments?issue=N |
GET | Returns customer-visible comments on issue N (team comments + any comment with the <!-- public --> marker) |
/api/status?numbers=N,N |
GET | Returns { number, state, reason } for up to 20 issue numbers — used by the "Your submissions" tracker so customers can see when an issue was declined or closed without ever being made public |
Four label namespaces do the work:
public— gates visibility. Without this label, an issue is invisible to customers.status:*— defines kanban columns (status:planned,status:in-progress,status:shipped).type:*— categorizes issues (type:bug,type:feature,type:improvement).area:*/env:*— capture affected area and environment for routing.
Two backends are provided. Pick one based on where you'd rather run it.
Cloudflare Worker (worker.js) |
VPS, nginx + PHP-FPM (submit.php + board.php) |
|
|---|---|---|
| Hosting | Cloudflare's edge | Your VPS |
| Image uploads | Not supported | Supported (WebP, on-disk) |
| Cost | Free tier covers 100k req/day | VPS only |
| Setup | wrangler CLI + secrets |
Drop in two PHP files + env vars |
| Storage | Stateless | Uploaded images live on the VPS |
| Caching | Cloudflare cache API (60s) | File cache in temp dir (60s) |
The PHP path is the one to choose if you want screenshots embedded in your issues.
index.html ships with a 3-step submission flow optimized for response rate:
- The basics — type (bug / feature / improvement), one-sentence summary, freeform description, submitter name and email, and optional screenshots (drag-and-drop, click, or paste from clipboard). Required: type, summary, description, name, email.
- Add detail (optional) — type-specific fields (repro steps and "is this blocking you?" for bugs; use case and success criteria for features/improvements). Routing context (area, environment, links) and "About you" (company, role) live behind disclosure blocks.
- Review & send — a summary of everything captured before submitting.
After submission, the customer sees a clear "Thanks — got it." success state with a copyable permalink (#issue=N URL). Once the team triages and adds the public label, that exact link opens the card on the roadmap. Pre-triage, it's a dead anchor that just loads the page.
There is no severity field. Triage decides priority — letting users self-assign severity is an anti-pattern (everyone picks "High").
The Submit tab also renders a small section listing every issue this browser has submitted, stored in localStorage only:
- Items already on the public board show their current column (Planned / In Progress / Shipped, with a ✓ for closed) and click through to open the modal.
- Items not yet visible show "Awaiting triage" in amber.
This is per-device (clears with browser data, doesn't sync across devices) and entirely client-side — no auth, no server tracking. The localStorage key is calliope-submissions and stores at most 12 entries: [{ number, title, submittedAt }]. Customers can clear it with the inline "Clear" button.
Visual: the portal uses the Clio KB design system (Geist font, zinc-tone palette, soft borders) and ships with a light/dark mode toggle in the header. Theme persists in localStorage and follows prefers-color-scheme on first visit.
Name and email are required so triage always has a way to follow up — but the data should never appear on the public roadmap. The flow:
submit.phpwrites a## Submittersection (name, email, company, role) and a## Submission metadatasection (page URL, user agent, language, screen size, time zone) into the GitHub issue body, wrapped in<!-- CUSTOMER_HIDE_START --> ... <!-- CUSTOMER_HIDE_END -->markers.- Team view (GitHub): GitHub's renderer hides the HTML comments themselves but still renders the markdown sections between them. You see the full submitter info in the issue UI, in
gh issue view, and via the API. - Customer view (
/api/boardand/api/comments):board.phpandcomments.phpstrip everything between the markers (and any other HTML comments) server-side before returning the JSON. Thebodyfield on the wire never contains PII — anyone inspecting the network response in browser devtools sees only the public sections. - Defense in depth: the modal's
renderBody()also strips the marker block before inserting into the DOM, so even if a body containing markers somehow reached the client (e.g. via a future endpoint that didn't apply the strip), the rendered output would still be clean.
To migrate older issues that pre-date this scheme, wrap the submission metadata section in markers via gh issue edit:
gh issue view N --json body --jq .body > body.md
# insert <!-- CUSTOMER_HIDE_START --> before "## Submission metadata"
# append <!-- CUSTOMER_HIDE_END --> at end
gh issue edit N --body-file body.mdAvailable with the PHP backend.
On the client:
- Drag and drop, click to browse, or paste from clipboard
- Up to 4 images, 10MB each
- Allowed: PNG, JPEG, GIF, WebP
- Thumbnail previews with per-image remove buttons
On the server (submit.php):
- Validates mime via magic bytes (not the
Content-Typeheader) - Decodes with GD, scales down to 2000px on the longest edge
- Re-encodes to WebP at quality 82 — typically 5–10× smaller than the source PNG, EXIF stripped as a side effect
- Saves to
<UPLOADS_DIR>/YYYY/MM/DD/<random-token>.webp - Embeds image URLs in the GitHub issue body under a
## Screenshotssection - If issue creation fails, freshly written files are deleted so orphans don't accumulate
Storage trade-off: images live on your VPS permanently. GitHub renders them via its camo image proxy, which caches but eventually re-fetches from origin — so if you delete a file, the image will eventually break in the issue. Plan backups for UPLOADS_DIR accordingly.
Use this path if you don't need image uploads.
1. Create the GitHub labels (see reference)
- Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token
- Repository access: select the specific repo
- Repository permissions:
Issues→ Read and write - Set a 90-day expiration with a calendar reminder to rotate
- Copy the token — you won't see it again
npm install -g wrangler
wrangler login
wrangler init customer-portal-worker
cd customer-portal-worker
cp /path/to/worker.js src/index.js
wrangler secret put GITHUB_TOKEN
wrangler secret put GITHUB_OWNER # e.g. "acme-corp"
wrangler secret put GITHUB_REPO # e.g. "feedback"
wrangler secret put ALLOWED_ORIGIN # e.g. "https://acme.com"
wrangler deployIn index.html:
const ENDPOINT = 'https://YOUR-WORKER.workers.dev'; // <-- replaceUpload index.html to your site.
Note: The Worker doesn't currently handle image uploads. Submitting a form with attachments will succeed but the images will be ignored. Use the PHP backend if you need screenshots in your issues.
Use this path if you want image uploads or already host on a VPS.
1. Create the GitHub labels (see reference)
sudo apt update
sudo apt install nginx php-fpm php-gd php-curlphp-gd provides imagewebp(). php-curl is used to talk to the GitHub API.
sudo mkdir -p /var/www/calliope
sudo cp submit.php board.php comments.php status.php /var/www/calliope/
sudo mkdir -p /var/lib/calliope/uploads
sudo chown -R www-data:www-data /var/lib/calliope/uploadsEdit your php-fpm pool config (e.g. /etc/php/8.2/fpm/pool.d/www.conf) and add:
env[GITHUB_TOKEN] = ghp_your_token_here
env[GITHUB_OWNER] = acme-corp
env[GITHUB_REPO] = feedback
env[ALLOWED_ORIGIN] = https://acme.com
env[UPLOADS_DIR] = /var/lib/calliope/uploads
env[UPLOADS_URL] = https://acme.com/uploads
env[STATUS_SIGN_KEY] = a-long-random-secret-string-rotated-occasionallySTATUS_SIGN_KEY is required for /api/status and the per-submission HMAC token returned by /api/submit. Generate with openssl rand -hex 32 or similar. Don't commit it. Rotating it invalidates all currently-saved submission tokens, which downgrades older entries in customers' "Your submissions" lists to silent "Awaiting triage" (the row still shows, just no live status lookup) — non-destructive but noisy, so rotate sparingly.
Reload php-fpm: sudo systemctl reload php8.2-fpm.
server {
listen 443 ssl http2;
server_name acme.com;
client_max_body_size 50m;
# Frontend
location / {
root /var/www/calliope;
try_files $uri $uri/ =404;
}
# Submission endpoint
location = /api/submit {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /var/www/calliope/submit.php;
include fastcgi_params;
}
# Board endpoint
location = /api/board {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /var/www/calliope/board.php;
include fastcgi_params;
}
# Comments endpoint
location = /api/comments {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /var/www/calliope/comments.php;
include fastcgi_params;
}
# Status endpoint
location = /api/status {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /var/www/calliope/status.php;
include fastcgi_params;
}
# Public uploads
location /uploads/ {
alias /var/lib/calliope/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff;
}
}Reload: sudo nginx -t && sudo systemctl reload nginx.
In index.html:
const ENDPOINT = 'https://acme.com'; // your domain — submit.php is at /api/submit- Open
index.htmlin your browser. - Submit a test issue with a screenshot. It should appear in your GitHub repo with
from-customerandtype:*labels, a structured Markdown body, and the screenshot embedded under a## Screenshotsheading. - Add
publicandstatus:plannedto the issue. After the cache expires, the card appears on the board.
In your repo, go to Issues → Labels → New label and create:
| Label | Suggested color | Purpose |
|---|---|---|
public |
#0E8A16 (green) |
Gates customer visibility |
status:planned |
#C5DEF5 (light blue) |
Column 1 |
status:in-progress |
#FBCA04 (yellow) |
Column 2 |
status:shipped |
#0E8A16 (green) |
Column 3 |
type:bug |
#D73A4A (red) |
Bug reports |
type:feature |
#0969DA (blue) |
Feature requests |
type:improvement |
#1F883D (green) |
Improvements |
area:auth |
#5319E7 (purple) |
Authentication |
area:billing |
#1D76DB (blue) |
Billing |
area:dashboard |
#0E8A16 (green) |
Dashboard |
area:integrations |
#006B75 (teal) |
Integrations |
area:notifications |
#C2E0C6 (mint) |
Notifications |
area:api |
#D4C5F9 (lavender) |
API |
area:export |
#F9D0C4 (peach) |
Export / reporting |
area:mobile |
#F7C6C7 (pink) |
Mobile |
area:performance |
#F9D0C4 (peach) |
Performance |
area:other |
#EDEDED (gray) |
Other |
env:production |
#B60205 (red) |
Production |
env:staging |
#FBCA04 (yellow) |
Staging |
env:sandbox |
#0E8A16 (green) |
Sandbox / test |
env:local |
#5319E7 (purple) |
Local / development |
env:unknown |
#EDEDED (gray) |
Unknown |
from-customer |
#FBCA04 (yellow) |
Auto-applied to submissions |
| Label | Effect |
|---|---|
public |
Issue appears on the board. No public label = invisible. |
| Label | Column |
|---|---|
status:planned |
Planned |
status:in-progress |
In Progress |
status:shipped |
Shipped |
| (none) | Defaults to Planned |
| Label | UI treatment |
|---|---|
type:bug |
Red pill |
type:feature |
Blue pill |
type:improvement |
Green pill |
Hidden from customers
These labels are stripped from card display:
publicfrom-customer- Any
status:*label - Any
type:*label (rendered as a colored pill instead)
Other labels (e.g., priority:high, area:billing) will be shown as small pills on cards. To hide additional labels, extend the filter list in worker.js (transformIssue function).
The card modal fetches /api/comments?issue=N when opened and renders comments below the body. By default, customers see:
- All comments from team members — anyone whose
author_associationon GitHub isOWNER,MEMBER, orCOLLABORATOR. - Any comment containing the marker
<!-- public -->— useful for surfacing a customer's own follow-up reply, or for a team member commenting from an account without write access.
The <!-- public --> marker is stripped from the rendered output, so you can drop it anywhere in the comment body.
Responses are cached per issue for 60 seconds (configurable via COMMENTS_CACHE_TTL).
Team comments are automatically customer-visible, but you'll often want to mix a public update with a private aside ("here's the ETA, and internally we should also check X"). Two ways to keep the private part hidden:
Inline notes — wrap them in <!-- ... --> HTML comments. Anything inside the comment is stripped server-side by comments.php before the API ever returns the body, and GitHub's web UI hides the comment from rendered view but still shows it in the comment editor / raw view / gh issue view.
Reproduced on staging. Will ship the fix in v1.42.
<!-- triage: revert the URL state refactor in #87 if this regresses again -->The customer sees only "Reproduced on staging. Will ship the fix in v1.42." The team sees the triage note when editing the comment or via the API.
Whole-block notes — wrap a multi-section aside in the same <!-- CUSTOMER_HIDE_START --> ... <!-- CUSTOMER_HIDE_END --> markers used in issue bodies. Useful when the internal aside has its own structure (multiple paragraphs, lists, headings):
Shipped in v1.42 — see the [docs](https://example.com/docs/slack) for setup.
<!-- CUSTOMER_HIDE_START -->
## Internal follow-up
- Confirm with legal whether two-way sync needs a DPA addendum
- Schedule a metrics check after 7 days of usage
<!-- CUSTOMER_HIDE_END -->Both forms are stripped server-side, so they never appear in the network response — not just hidden in the rendered DOM.
| You want… | Put this in the comment |
|---|---|
| Public update from a team account | Just write it. Comments from OWNER, MEMBER, or COLLABORATOR are visible by default. |
| Public update from a non-team account (or a customer reply you want to surface) | Include <!-- public --> anywhere in the body. |
| A short internal note alongside a public one | <!-- internal: don't escalate, customer was on a stale build --> |
| A whole internal section (multi-line, headings, lists) | Wrap it in <!-- CUSTOMER_HIDE_START --> ... <!-- CUSTOMER_HIDE_END --> |
| A fully internal comment (no public part) | Either wrap the whole thing in <!-- ... -->, or just write it without any <!-- public --> marker from a non-team account — non-team comments default to hidden. |
To verify a comment was filtered correctly, hit the endpoint directly and check the JSON:
curl -s https://yourdomain.com/api/comments?issue=42 | jq '.comments[] | .body'If you can see the internal text in that output, the strip didn't work — review the markers and check that the cache is busted (delete calliope-comments-*.json from BOARD_CACHE_DIR / sys_get_temp_dir()).
When a customer submits feedback:
-
A new issue appears in your repo with:
- Labels:
from-customer,type:*, plusarea:*andenv:*if the user filled them in - A structured Markdown body with description, type-specific fields, screenshots (if uploaded), and submission metadata
- Labels:
-
Triage:
- Spam or duplicate? Close the issue.
- Legitimate? Add
public, astatus:*label, and any internal labels (priority:*, etc.).
-
Working on it? Swap
status:plannedforstatus:in-progress. Card moves columns within 60 seconds. -
Shipped? Swap to
status:shippedand close the issue.
Tip: Save a GitHub filter for
is:open label:from-customer -label:public— that's your triage queue.
In worker.js, edit STATUS_COLUMNS:
const STATUS_COLUMNS = [
{ key: 'backlog', name: 'Backlog' },
{ key: 'planned', name: 'Planned' },
{ key: 'in-progress', name: 'In Progress' },
{ key: 'shipped', name: 'Shipped' },
];Create matching status:backlog labels in GitHub. The frontend's .kanban grid adapts automatically; consider adjusting grid-template-columns for the new count.
In worker.js, edit ALLOWED_TYPES. In submit.php, edit the ALLOWED_TYPES constant. In index.html, add a matching type card and a .pill.<newtype> CSS rule.
In worker.js, find 'Cache-Control': 'max-age=60' in handleBoard.
ALLOWED_ORIGIN enforces this. It must match your site's origin exactly — https://acme.com and https://www.acme.com are different origins.
In submit.php, edit the constants at the top:
MAX_FILES— how many images per submission (default 4)MAX_FILE_SIZE— per-file cap in bytes (default 10MB)MAX_DIMENSION— longest edge in pixels (default 2000)WEBP_QUALITY— 0–100 (default 82, sweet spot for screenshots)
The matching client-side caps live near the top of the <script> in index.html (MAX_FILES, MAX_SIZE, ALLOWED_MIMES). Keep them in sync.
- The GitHub token must never appear in client code. Stored as a Worker secret or php-fpm env var. Don't log it, don't echo it in error responses, don't commit it.
- Use fine-grained PATs scoped to a single repo. Limits blast radius if a token leaks.
- Rotate tokens regularly. 90-day expiration with a calendar reminder.
- Image uploads are re-encoded server-side. GD decoding + WebP re-encoding strips EXIF metadata and neutralizes any payload embedded in the original file. Don't skip this step if you accept user uploads.
- Magic-byte mime validation.
submit.phpusesfinfoto check actual file content, not the client-suppliedContent-Type. Clients lie. - Random filenames. Uploaded images are stored under
<random-token>.webpso URLs aren't enumerable. - Honeypot. Both backends silently accept (and discard) submissions where the hidden
websitefield is filled — catches lazy bots. - Rate-limiting. For real spam, add Cloudflare Turnstile (free) — one script tag in the form and a verify call in the backend. nginx's
limit_req_zoneis also a fine first line of defense for the PHP path. - Sanitize what's exposed. The Worker's
transformIssueandboard.php'stransform_issuecontrol what the board endpoint returns. Internal labels and comment threads are not exposed by default. - PII never leaves the server in the read APIs.
board.phpandcomments.phpstrip the<!-- CUSTOMER_HIDE_START --> ... <!-- CUSTOMER_HIDE_END -->block from each issue/comment body before returning it. Verify with browser devtools → Network → check the raw/api/boardresponse after submitting a test ticket.
Cards don't appear after I add public.
The board response is cached for 60 seconds. Wait, then hard-refresh.
Form submission returns "Could not submit."
- Worker:
wrangler tailfor logs. Usually token expired, missingIssues: Writescope, or wrong owner/repo. - PHP: check
/var/log/nginx/error.logand your php-fpm error log. Usually the same root causes, plusUPLOADS_DIRpermissions.
"Server upload path not configured."
PHP backend: UPLOADS_DIR or UPLOADS_URL env var is missing. Verify your php-fpm pool config and that you reloaded php-fpm.
Images upload but don't appear in the issue.
Check that the UPLOADS_URL you set is publicly reachable. Open one of the URLs in an incognito window — if you can't see the image, GitHub can't either.
Access-Control-Allow-Origin errors in the browser console.
ALLOWED_ORIGIN must match your site's origin exactly (including https://, no trailing slash).
Submitted issues appear immediately on the board.
Check that you didn't include public in the labels array on the backend. Submissions get from-customer + type:* only — public is added during triage.
Pull requests show up on the board.
GitHub's /issues endpoint returns PRs too; the Worker filters them with if (!i.pull_request). Make sure that filter is intact.
imagewebp(): WebP support not enabled in PHP error log.
php-gd was installed without WebP support. On Debian/Ubuntu, apt install php-gd includes it by default; on minimal images you may need to recompile or use php-imagick as an alternative.
Stack: GitHub Issues + Labels (data) · Cloudflare Workers or nginx + PHP-FPM (proxy) · Vanilla HTML/CSS/JS (frontend)
Files: index.html, worker.js, submit.php, board.php, comments.php
MIT — see LICENSE.
Calliope was the eldest daughter of Zeus and Mnemosyne — goddess of memory — and the patron of epic narrative. Every roadmap is a story your customers want to follow from beginning to end.