Callboard
Archived hackathon project. Callboard was built in August 2026 for the AI Engineer “Kill My SaaS” challenge. It is no longer maintained, and the hosted demo and project site were taken offline in October 2026, so the
callboardhq.comlinks below no longer resolve. The code stays MIT-licensed: see Local development to run it yourself.
Open-source speaker and call-for-proposals management for events.
Callboard covers the workflow from public submission through organizer review, speaker onboarding, agenda publishing, communications, and API/integration exports. It was built for the AI Engineer “Kill My SaaS” challenge as a fast, self-hostable alternative to Sessionboard.
A callboard is the physical backstage board where cast calls, schedules, and notices are posted—the original speaker-management system.
Try the demo
The hosted demo and project site are offline (October 2026). During judging they ran at demo.callboardhq.com and callboardhq.com; the steps below describe that demo and work against a local or self-hosted copy.
- Event page: https://demo.callboardhq.com/e/frontier-ai-summit-2026
- One-click judge roles (organizer and speaker): https://demo.callboardhq.com/demo
Judging this? SUBMISSION_EVIDENCE.md maps every rubric area to the exact URLs that serve it, with one thing to try on each.

That environment is a disposable, seeded demo. It is periodically reset to the deterministic seed, so anything created there is temporary and will be wiped, and it sends no real mail. Never upload private or sensitive information to it.
A self-hosted deployment behaves differently by default: the checked-in
configuration is production-profiled and returns 404 from /demo. One-click
organizer and speaker sessions exist only on a disposable demo deployment
created from wrangler.demo.example.jsonc.
Suggested walkthrough:
- Open
/demo, choose Enter organizer workspace, and follow its six-stop judge path. - Open Forms to inspect conditional questions, shared character budgets, routing, close dates, and participant rules.
- Open Submissions to inspect the seven-state decision pipeline and commit staged accept/decline queues.
- Open Agenda and move a session on the day board; then inspect the dedicated conflict view and published schedule.
- Return to
/demo, choose Enter speaker portal, then update the profile, complete tasks, upload materials, and read event resources. - Visit Integrations, API keys, and
/developersfor exports and the Sessionboard-compatible API surface.
What is implemented
| Area | Current capability |
|---|---|
| Organizer dashboard | Producer-facing programme readiness with explicit blockers and next actions across submissions, speakers, tasks, schedule, rooms, and publication, plus a "Set up your event" checklist for initial event configuration |
| CFP builder | Six-step organizer flow, reusable field registry, conditional logic, cross-field character limits, category/track routing, participant bounds, close dates, limits, and cloning |
| Public submission | Five-step mobile-first SSR flow, drafts, email-verified account entry, abstract/video modes, validation, confirmation email, and portal redirect |
| Submission decisions | Seven status views plus a drag-and-drop kanban board (/admin/submissions/board), staged accept/decline queues, bulk commit, filtering, drill-in, and manual additions |
| Review operations | Standalone assigned-only reviewer workspace, organizer “view as reviewer,” multi-round weighted scoring, reviewer aggregates and completion progress, reviewer provisioning by email with an additive capability flag, team management, round/rubric setup (add/remove criterion rows, numeric, dropdown, or free-text types, lock on first submitted score), per-round blinding, reviewer conflict-of-interest recusal, reviewer reminders, and both batch assignment and direct per-reviewer assignment |
| Score reporting | Aggregate-score sorting in the submissions list, plus a reviewer score CSV export at /admin/submissions/scores.csv carrying one column per round's aggregate, one column per dropdown criterion's answer distribution, one column per free-text criterion's answers, reviewer comments, and the advisory AI-triage opinion kept in its own trailing columns |
| AI first-pass triage | Advisory Workers AI triage on an abstract's detail page: a score, a queue recommendation, and a short rationale carrying the model label. It never writes a review, never changes a submission's status, and degrades to a visible "unavailable in this deployment" when no AI binding is present |
| Speaker CRM | Organization-level contact directory spanning every event, with contact profiles, notes, tags, travel notes, CSV import (preview then commit), duplicate detection and merge, add-to-event, bulk email to a filtered selection, rollups for total contacts/events/returning contacts/top companies, savable named filter segments, and a sourcing pipeline (a kanban board over five fixed stages; each card moves either by dragging or by picking a stage and submitting) with a move-audit trail |
| Files library | Organizer-side browser of every upload for an event, grouped into version chains (newest deliverable first), with per-file comments and a bulk ZIP download |
| Multi-event | Multiple events per deployment, an admin event switcher that scopes every organizer screen, and in-app event creation |
| Public & embeddable widgets | Public event page, schedule with search/day tabs/track-format-room facets and clickable speaker names, session detail, speaker directory/profiles/gallery with consent-gated headshots (monogram fallback when a speaker has not opted in), a unified “Show more” control, browser-local “My schedule” itinerary, and an .ics feed. An organizer embed builder generates four chrome-less widgets (schedule, agenda by day, speakers, speaker gallery) with theme, density, accent, track, custom-CSS, and field-hiding options, emitted as an iframe snippet, a plain link, a calendar feed, or a raw JSON/XML data feed. Saved embeds get a stable ?embed=<id> URL and can be enabled, disabled, or deleted. Full option reference: docs/guides/embed-your-schedule.md |
| Resource/wiki operations | Event-scoped create/edit/order, sanitized preview, publish/unpublish, and recoverable archive-as-unpublish |
| Speaker portal | Status, deadline-gated pending corrections, accepted title/abstract/video corrections with programme-copy sync, profile, headshot/material uploads, tasks, portal forms, resources, and organizer impersonation |
| Communications | Editable templates, confirmation email, task reminders, communication log, and ICS REQUEST/UPDATE/CANCEL lifecycle |
| Agenda | List/day/week/track/room/conflict views, pointer-correct drag and drop, no-JavaScript fallback, room and speaker overlap detection, an “Auto-place remaining” action that fills unscheduled sessions into conflict-free room/time slots, room and track CRUD, organizer session editing with attributed revision history and one-click restore, publishing that holds until speakers have been informed with an explicit per-session override, and public schedule with ordered speaker/co-speaker names |
| Speaker roster | Add and edit speakers, participation status, portal-invite send, CSV import, and portal-form custom fields from organizer assignment through speaker answers |
| API | Scoped keys across six independently grantable scopes (four read, two write — none cascade), consistent envelopes, sessions/speakers/metadata endpoints, OpenAPI document, and developer page |
| Integrations | Byte-exact Accelevents CSV pair, optional API push when configured, non-blocking Airtable mirror, and built-in signed webhooks with optional Svix delivery |
| Infrastructure | One Cloudflare Worker for the main application (D1, R2, React Router framework mode, Drizzle, Tailwind, Vitest, Playwright), plus a separate MCP Worker with no product bindings — see Agents: point your MCP client here |
Limitations
Known open gaps, verified against the commit this file ships in. Each one names the file or command that shows it, so it can be rechecked rather than taken on trust.
Volatile counts for the commit this file ships in, one reproducing command each: ls app/db/migrations/*.sql | wc -l → 22 migrations;
grep -c 'sqliteTable(' app/db/schema.ts → 41 tables;
grep -oE 'insert\("[a-z_]+"' scripts/seed.mjs | sort -u | wc -l → 29 seeded
tables; npx vitest list --run | grep -c ' > ' → 2947 tests.
AI triage is advisory only and never decides. Workers AI (the "ai" binding
in wrangler.jsonc, model @cf/meta/llama-3.3-70b-instruct-fp8-fast) produces a
first-pass score, a queue recommendation, and a short rationale on an abstract's
detail page (app/lib/review/ai-triage.server.ts,
app/components/ai-triage-card.tsx). It never writes a reviews row, never
appears in the reviewer CSV, and never changes a submission's status — the
committee decides. A deployment with no AI binding still runs; every triage
surface degrades to a visible “unavailable in this deployment.” The agenda's
“Auto-place remaining” is a separate deterministic conflict-aware planner
(app/lib/agenda/autoplace.ts), not a model.
Submission statuses are a fixed set of seven. SESSION_STATUSES in
app/db/schema.ts is a hard-coded list — draft, pending, accept_queue,
accepted, decline_queue, declined, withdrawn. Organizers cannot rename,
reorder, or add one. Submissions can be worked from filtered list views and a
drill-in, or from a drag-and-drop kanban board (/admin/submissions/board,
app/routes/admin.submissions.board.tsx) — but the board's columns are exactly
those seven fixed statuses, not an organizer-configurable status set.
The CRM sourcing pipeline's stages are fixed.
The kanban at /admin/pipeline lays contacts out in five hard-coded stage
columns (PIPELINE_STAGES in app/db/schema.ts: prospect, contacted,
in_conversation, confirmed, declined). A card can be moved by dragging it
between columns or with its stage select and Move button; both submit the
same audited move action. There is still no stage-management UI to add, rename,
reorder, or remove a stage: the ordering is product workflow, not organizer
configuration.
The bulk file ZIP selection is capped at 30 MB of source bytes. The Files
library at /admin/files lists every upload for an event grouped into version
chains with per-file comments, and /admin/files/download streams a selection
as a ZIP. MAX_ZIP_BYTES in app/lib/zip.ts is 30 * 1024 * 1024: the archive
is assembled in Worker memory (deliberately not Zip64), so a selection whose
declared source bytes total over the cap is refused, with its own size quoted,
before any object is fetched. The cap bounds the source selection, not the
finished archive: buildZip (same file) adds a local header and a
central-directory record per entry plus filenames and an end-of-central-directory
record, so a selection sitting right at the cap produces a ZIP slightly larger
than 30 MB, not one capped at exactly that size.
Saved segments are deployment-global and name-keyed. A Contacts filter
(search, company, title, event, notes, tag) can be saved as a named segment
(contact_segments in app/db/schema.ts), but the table has no owner column and
a unique index on the name: every organizer on the deployment sees the same
segments, there is no private or per-organizer segment, and saving over an
existing name overwrites it (save-segment and delete-segment in
admin.contacts.tsx are the only actions).
Contact merge is one-way. Merging sets people.merged_into on the losing
row and re-parents its memberships, tags, notes, and session participations.
There is no unmerge action; the UI says so before you confirm.
Custom-field tasks provisioned by a later acceptance carry no due date.
Assigning a portal form writes an absolute due date onto the speakers chosen at
the time, but the reusable template it can also write stores
dueOffsetDays: null (admin.tasks.tsx), because an absolute date cannot be
turned into the relative offset templates hold. commit.server.ts maps that
null straight through to a null dueAt, so speakers accepted later get the
questions without a deadline until the organizer sets one on the task.
Saved embeds cannot be edited in place. The save action always mints a new
crypto.randomUUID(), so changing a widget's theme, track, or accent means
saving a second embed and repasting its snippet on the host page. Existing
embeds can be enabled, disabled, or deleted, but not amended.
Embed track filters are only rename-safe when saved. Saved embeds store the
track id and resolve the name at render, so a saved embed survives a track
rename. Two cases do not: a snippet copied straight from a widget card carries
?track=<name> and is matched by name at render
(app/lib/embeds.server.ts), and saved rows written before the id-keying change
still hold a name and resolve to themselves. Either one silently becomes an
empty widget on somebody else's page when the track is renamed. Re-save any
embed whose track filter matters after renaming a track.
The public “My schedule” itinerary is browser-local. Starred sessions live
in window.localStorage in app/routes/public.schedule.tsx. It needs
JavaScript, does not sync across devices or browsers, is not tied to an account,
and is lost when site data is cleared. The rest of the public schedule —
search, day tabs, track/format/room facets — works without JavaScript.
Review blinding hides structured identity, not prose. A blinded round's
session_participants query never selects an identity column — only a
count() is projected (app/routes/review.index.tsx:136-145) — so
name, email, company, and title never reach the reviewer. The abstract body is
shown verbatim, so a submitter who names themselves in their own text is still
identifiable, and un-blinding a round does not retroactively hide what a
reviewer already saw before you re-checked the box. The claim is checkable in
the reviewer query itself (app/routes/review.index.tsx:114-143): the blinded
branch never selects the identity columns, so they cannot reach the wire.
The review engine generalizes to awards and grants judging; awards
vocabulary does not exist yet. Weighted rubrics, enforced per-round
blinding, reviewer recusal, and multi-round assignment are judging primitives,
not CFP-specific ones — nothing in app/lib/review/ assumes "submission" over
"nominee" or "applicant." Renaming the vocabulary and shipping a public
winners page is deliberate post-competition roadmap, not a gap in what ships
today.
Reviewer invitations report the delivery state the mailer actually observed,
never a presumed send. An outside reviewer can be provisioned by email and
gains the capability immediately (invite-reviewer in admin.reviews.tsx),
and — on a deployment with real mail configured — the same sign-in email every
other magic link uses is genuinely sent. The invite panel reports whichever
state the mailer actually returned (sent only when a configured provider
accepted the message, logged on the console driver, failed or unknown
otherwise), so it never claims a delivery it did not observe. Team membership
is still restricted to reviewer-capable people —
people.role = "admin", an event role in admin/organizer/reviewer, or the
additive event_people.is_reviewer flag — so an arbitrary contact cannot be
dropped onto a review team without being provisioned first.
Revision history is read without a bound. Organizer session edits and speaker
corrections are versioned in session_revisions and restorable, with attribution,
from /admin/sessions/<id> (app/lib/admin/session-revisions.server.ts). The
history query orders by recency with no LIMIT, so a session edited a very large
number of times reads its entire revision list in a single loader pass.
One D1 database, one region. wrangler.jsonc declares a single DB binding
with no read replication configured, so read latency is bounded by the
database's primary region regardless of where the Worker runs.
Uploads are Worker-proxied and capped at 25 MB. MAX_UPLOAD_BYTES in
app/lib/portal-uploads.ts is 25 * 1024 * 1024. There are no presigned URLs:
every byte in and out passes through the Worker so ownership checks stay on both
paths, which also makes 25 MB a deliberate ceiling rather than a tunable.
The judge demo sends no real mail. The demo template pins
MAIL_DRIVER=console, so confirmation emails, invitations, reminders, and
decision notifications are printed to the Worker log instead of delivered.
Demo mode reveals the magic link in the sign-in response so a judge can proceed
without a mailbox. Real delivery is a production feature, not a gap: the
Resend driver ships in this repo and native Gmail calendar invites were
verified end-to-end (app/lib/comms/ics.ts:5). The judged demo keeps mail on
the console deliberately — an anonymous-writable demo must not be able to
email strangers. Want to see live delivery? Ask, and we'll stand up a
mail-enabled instance scoped to addresses you control. Outlook ICS lifecycle rendering remains unverified.
Submission evidence, including the requirement-to-screen map, is in SUBMISSION_EVIDENCE.md.
Verify it yourself
Every row is a claim this README makes, paired with the exact command or URL
that proves or disproves it. Repository commands run from a clean checkout after
npm ci. The same rule holds for every figure in this README, not just this
table: each one is either generated from the deployment (a constant named in
its source file, a count with the command that produces it) or paired with the
command that reproduces it.
| Claim | How to check it |
|---|---|
| The migration, table, and seeded-table counts are real | Run the three commands stamped at the top of Limitations; each prints the number quoted beside it |
| The test suite is the size claimed | npx vitest list --run | grep -c ' > '. Limitations stamps this count for the commit the file ships in — run it against your checkout and it should match exactly. (Piping to plain wc -l also counts each worker process's one-line Node ExperimentalWarning banner, so raw line count varies run to run — filter to lines matching ' > ', vitest's list-item separator, for a stable number.) |
| The whole gate passes three consecutive isolated times | npm run release:verify:repeat — writes one attempt per pass to artifacts/release-gate/<sha>/manifest.json and only sets "result": "passed" after three |
| CI runs that same gate, not a lighter one | .github/workflows/check.yml and .github/workflows/repeat-release-gate.yml |
| Calendar invites actually land in a real calendar | app/lib/comms/ics.ts:5 records the end-to-end verification against Gmail on 2026-08-08: native RSVP card, event auto-staged, conflict detection ran |
| AI triage never decides anything | `grep -nE '.(inse |
