Getting Started with Alloy
Go from zero to managing tickets in 5 minutes — using nothing but curl.
Prerequisites
- Rust toolchain (1.75+): Install via rustup if you don’t have it
- Git: To clone the repository
- curl and jq: For interacting with the API
Or use a prebuilt binary from the Releases page — no Rust toolchain needed.
Step 1: Start the Server
Alloy runs in SQLite mode by default — no database setup, no Docker, no config files. JWT signing keys are auto-generated if not configured.
# Clone and enter the repo
git clone https://github.com/your-org/alloy.git
cd alloy
# Build the release binary
cargo build --release
# Start the server with open registration enabled
ALLOY_REGISTRATION=open ./target/release/alloy serve
Expected output:
WARN: No JWT keys configured — auto-generating Ed25519 key pair for development.
Set ALLOY_JWT_PRIVATE_KEY / ALLOY_JWT_PUBLIC_KEY for production.
Running migrations...
Migrations complete.
Listening on 0.0.0.0:3000
Note: The auto-generated keys are ephemeral — they change on every restart, which invalidates existing tokens. For production, set
ALLOY_JWT_PRIVATE_KEY_FILEandALLOY_JWT_PUBLIC_KEY_FILEpointing to Ed25519 PEM files. See the Deployment Guide for details.
Leave this terminal running and open a new one for the remaining steps. Set the base URL variable:
BASE_URL="http://localhost:3000"
Step 2: Register a User
Create your first user account via the registration endpoint:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "tutorial@alloy.dev",
"password": "tutorial1",
"display_name": "Tutorial User"
}' | jq .
{
"user_id": "a1b2c3d4-...",
"email": "tutorial@alloy.dev",
"display_name": "Tutorial User",
"access_token": "eyJ..."
}
Save the token and user ID for subsequent requests:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
CLI equivalent:
cargo run -p alloy-cli -- auth login --email tutorial@alloy.dev --password tutorial1
Step 3: Create an Organization
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Tutorial Corp",
"slug": "tutorial-corp"
}' | jq .
{
"id": "b2c3d4e5-...",
"name": "Tutorial Corp",
"slug": "tutorial-corp"
}
Save the org ID:
ORG_ID="<id from above>"
Verify it was created:
curl -s "$BASE_URL/api/v1/orgs" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "b2c3d4e5-...",
"name": "...",
"slug": "..."
}
]
}
CLI equivalent:
cargo run -p alloy-cli -- onboard --email tutorial@alloy.dev --password tutorial1 --org-name "Tutorial Corp" --org-slug tutorial-corp
Roles and Permissions
When you created the organization above, Alloy automatically assigned you the Owner role — the most privileged role available. Every organization member has exactly one of five roles:
| Role | Description |
|---|---|
| Owner | Full control. Assigned automatically to the org creator. |
| Admin | Manage workflows, teams, invites, and delete resources. |
| Member | Create and update projects, tickets, sprints, and time entries. |
| Reporter | Create tickets and comments. Limited to assigned projects. |
| Viewer | Read-only access to all resources. |
Inviting a Team Member
As an Owner, you can invite others to join your organization. Each invite specifies the role the new member will receive:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"email\": \"teammate@example.com\",
\"role\": \"member\",
\"created_by\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "teammate@example.com",
"role": "Member",
"expires_at": "..."
}
Share the invite_link with your teammate. They can register using the invite code to join with the specified role.
For the full permission matrix showing which role is required for each operation, see the Roles and Permissions section of the API Reference.
Step 4: Create a Project
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"DEMO\",
\"name\": \"Demo Project\",
\"description\": \"A demo project for Alloy\"
}" | jq .
{
"id": "c3d4e5f6-...",
"org_id": "b2c3d4e5-...",
"key": "DEMO",
"name": "Demo Project",
"description": "A demo project for Alloy"
}
Save the project ID:
PROJECT_ID="<id from above>"
Read it back to confirm:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "c3d4e5f6-...",
"key": "DEMO",
"name": "Demo Project",
"description": "A demo project for Alloy"
}
CLI equivalent:
cargo run -p alloy-cli -- project create --org-id "$ORG_ID" --key DEMO --name "Demo Project"
Step 5: Create Your First Ticket
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Set up CI pipeline\",
\"description\": \"Configure GitHub Actions for build and test\",
\"status\": \"Backlog\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "d4e5f6g7-...",
"title": "Set up CI pipeline",
"description": "Configure GitHub Actions for build and test",
"status": "Backlog",
"priority": "High",
"reporter_id": "a1b2c3d4-..."
}
Save the ticket ID:
TICKET_ID="<id from above>"
CLI equivalent:
cargo run -p alloy-cli -- ticket create --title "Set up CI pipeline" --priority high
Step 6: Add a Comment
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"body\": \"Looks good, let's ship it\",
\"author_id\": \"$USER_ID\"
}" | jq .
{
"id": "e5f6g7h8-...",
"body": "Looks good, let's ship it",
"author_id": "a1b2c3d4-...",
"ticket_id": "d4e5f6g7-..."
}
CLI equivalent:
cargo run -p alloy-cli -- comment create --ticket-id "$TICKET_ID" --body "Looks good, let's ship it"
Step 7: List Tickets
Verify everything by listing tickets in the project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "d4e5f6g7-...",
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "High"
}
]
}
CLI equivalent:
cargo run -p alloy-cli -- ticket list
You’re up and running!
Seed a Full Demo
Want a richer dataset to explore? The included seed script creates an org, user, project, labels, a sprint, six tickets, comments, and time entries — all with read-back validation:
BASE_URL=http://localhost:3000 bash scripts/seed-demo.sh
Learn More
References
- API Reference — Full endpoint documentation, field tables, and permission matrix
- CLI Reference — Complete list of commands and options for the Alloy CLI
- TUI Guide — Interactive terminal UI for browsing and managing work
- MCP Guide — Use Alloy through the Model Context Protocol with AI assistants
- MCP Tools Reference — Detailed reference for all MCP tool parameters and behavior
- Deployment — Production deployment with PostgreSQL, Docker, TLS, and multi-tenancy
Guides
- Projects & Tickets — Create and manage projects, tickets, and assignments
- Sprints & Boards — Plan sprints, track velocity, and visualize progress
- Workflows & Statuses — Define custom workflows and ticket lifecycle rules
- Time Tracking & Finance — Log time, approve entries, and generate financial reports
- Teams, Roles & Permissions — Manage org members, roles, and access control
- Labels, Tags & Organization — Categorize and filter work with labels and tags
Use Cases
- For Engineering Managers — Sprint planning, team velocity, and status reporting
- For Finance Teams — Time reports, capitalization, and budget tracking
- For DevOps & Platform Teams — Automation, API integration, and infrastructure workflows
- For Individual Developers — Daily workflow, ticket management, and time logging
- For Project Owners & Managers — Roadmap planning, cross-team coordination, and delivery tracking
Playbooks
- Running a Sprint — Step-by-step guide for PMs to plan and execute sprints
- Quarterly Finance Reporting — End-to-end reporting workflow for CFOs
- Setting Up a New Team — Onboarding checklist for engineering managers
- Automating Your Workflow — Recipes for DevOps teams to automate with the API
Tutorials
- End-to-End Walkthrough — Complete tutorial covering all major features
- API Automation with curl and jq — Script common workflows with shell commands
- Authentication Deep Dive — SSO, API keys, and token management explained
- MCP Workflow — Build AI-powered project management workflows
Alloy CLI Reference
Alloy is a headless, API-first project management tool. The CLI (alloy) lets you
manage organizations, projects, tickets, sprints, time entries, and more from
your terminal. For the HTTP API, see the API Reference. New to
Alloy? Start with the Getting Started guide.
Global Flags
Every command accepts:
| Flag | Env Var | Default | Description |
|---|---|---|---|
--format <json|table|plain> | ALLOY_FORMAT | auto-detect | Output format |
--api-url <URL> | ALLOY_API_URL | http://localhost:3000 | API server URL |
Output Formats
The --format flag controls how results are displayed:
| Format | Description |
|---|---|
json | Machine-readable JSON (pretty-printed) |
table | Human-readable columns with headers |
plain | Minimal output suitable for scripting |
TTY auto-detection: When output goes to an interactive terminal, format
defaults to table. When piped or redirected, it defaults to json. Override
with --format or the ALLOY_FORMAT environment variable.
The NO_COLOR environment variable suppresses ANSI color codes when set to any
non-empty value.
Commands
health
Check API server connectivity and status.
alloy health [--format <FORMAT>]
| Flag | Description |
|---|---|
--format | Output format (json, table, plain) |
Examples:
# Quick status check
alloy health
# Machine-readable health check for monitoring
alloy health --format json
migrate
Run database migrations against the configured database.
alloy migrate
Uses ALLOY_DATABASE_URL (default: sqlite://alloy.db). Migrations are
embedded in the binary — no external migration files needed at runtime.
| Env Var | Default | Description |
|---|---|---|
ALLOY_DATABASE_URL | sqlite://alloy.db | Database connection URL |
Examples:
# Run migrations against default SQLite database
alloy migrate
# Run migrations against PostgreSQL
ALLOY_DATABASE_URL=postgres://localhost/alloy alloy migrate
serve
Start the Alloy API server.
alloy serve [--db <URL>] [--port <PORT>]
| Flag | Env Var | Default | Description |
|---|---|---|---|
--db <URL> | ALLOY_DATABASE_URL | sqlite://alloy.db | Database URL |
--port <PORT> | PORT | 3000 | Listen port |
Examples:
# Start with defaults (SQLite on port 3000)
alloy serve
# Start with PostgreSQL on a custom port
alloy serve --db postgres://localhost/alloy --port 8080
auth
Authenticate with the Alloy API and manage API keys.
auth login
Log in with email and password. Stores credentials locally.
alloy auth login --email <EMAIL> --password <PASSWORD>
| Flag | Description |
|---|---|
--email <EMAIL> | Account email address |
--password <PASSWORD> | Account password |
Credentials are saved to ~/.config/alloy/credentials.toml and include the
access token, refresh token, user ID, and email. The CLI automatically refreshes
expired tokens on 401 responses.
Examples:
# Log in and save credentials
alloy auth login --email admin@example.com --password s3cret
# Log in and capture just the user ID
alloy auth login --email admin@example.com --password s3cret --format plain
auth token
Display the currently stored authentication token (masked for security).
alloy auth token
Shows the first 12 and last 4 characters of the token with the middle masked.
Examples:
# Check which account is logged in
alloy auth token
# Get token info as JSON
alloy auth token --format json
auth api-key create
Create a new API key with optional scope and project restrictions.
alloy auth api-key create --name <NAME> [--scopes <SCOPES>] [--projects <PROJECT_IDS>]
| Flag | Default | Description |
|---|---|---|
--name <NAME> | (required) | Human-readable key name |
--scopes <SCOPES> | read,write | Comma-separated: read, write, admin |
--projects <IDS> | all projects | Comma-separated project UUIDs to restrict access |
API keys are prefixed alloy_live_ (production) or alloy_test_ (testing).
The full key is shown only once at creation time.
Scopes:
read— Read-only access to resourceswrite— Create, update, and delete resourcesadmin— Full access including user and org management
Examples:
# Create a read-write key for CI
alloy auth api-key create --name "CI Pipeline"
# Create a read-only key restricted to one project
alloy auth api-key create --name "Dashboard" --scopes read --projects 550e8400-e29b-41d4-a716-446655440000
# Create an admin key and capture it for scripting
alloy auth api-key create --name "Admin Script" --scopes read,write,admin --format plain
auth api-key list
List all API keys for the current user.
alloy auth api-key list
Examples:
# View all API keys
alloy auth api-key list
# List keys as JSON for automation
alloy auth api-key list --format json
auth api-key revoke
Revoke an API key by its ID.
alloy auth api-key revoke <ID>
Examples:
# Revoke a specific key
alloy auth api-key revoke 550e8400-e29b-41d4-a716-446655440000
# List keys then revoke one
alloy auth api-key list --format json | jq '.[0].id' -r | xargs alloy auth api-key revoke
org
Manage organizations (tenants).
org create
alloy org create --name <NAME> --slug <SLUG>
| Flag | Description |
|---|---|
--name <NAME> | Organization display name |
--slug <SLUG> | URL-friendly identifier (e.g. acme-corp) |
Examples:
# Create a new organization
alloy org create --name "Acme Corp" --slug acme-corp
# Create and capture org ID
ORG_ID=$(alloy org create --name "Acme Corp" --slug acme-corp --format json | jq -r '.id')
org list
alloy org list
Examples:
# List all organizations
alloy org list
# Get org IDs for scripting
alloy org list --format json | jq '.[].id'
org switch
Set the active organization for subsequent commands.
alloy org switch <ID>
Examples:
# Switch active organization
alloy org switch 550e8400-e29b-41d4-a716-446655440000
# Switch to the first org
alloy org switch $(alloy org list --format json | jq -r '.data[0].id')
project
Manage projects within an organization.
project create
alloy project create --org-id <ORG_ID> --key <KEY> --name <NAME> [--description <DESC>] [--team-id <TEAM_ID>]
| Flag | Description |
|---|---|
--org-id <ORG_ID> | Organization UUID |
--key <KEY> | Short project key, 1-10 chars (e.g. BACK, MOBILE) |
--name <NAME> | Project display name |
--description <DESC> | Optional description |
--team-id <TEAM_ID> | Optional team UUID |
Examples:
# Create a backend project
alloy project create --org-id $ORG_ID --key BACK --name "Backend Services"
# Create with description
alloy project create --org-id $ORG_ID --key WEB --name "Web Frontend" --description "React SPA and design system"
project list
alloy project list --org-id <ORG_ID> [--cursor <CURSOR>] [--limit <LIMIT>]
| Flag | Default | Description |
|---|---|---|
--org-id <ORG_ID> | (required) | Organization UUID |
--cursor <CURSOR> | — | Pagination cursor from previous response |
--limit <LIMIT> | 20 | Results per page |
Examples:
# List all projects in an org
alloy project list --org-id $ORG_ID
# Page through results
alloy project list --org-id $ORG_ID --limit 5 --cursor "next_cursor_value"
project get
alloy project get <ID>
Examples:
# Get project details
alloy project get 550e8400-e29b-41d4-a716-446655440000
# Get project as JSON
alloy project get $PROJECT_ID --format json
project update
alloy project update <ID> [--name <NAME>] [--description <DESC>] [--team-id <ID|none>] [--cap-type <capex|opex|none>] [--phase <preliminary|app-development|post-implementation|none>] [--cost-center-id <ID|none>] [--amortization-months <INT|none>]
Pass none to any optional field to unset it.
Examples:
# Rename a project
alloy project update $PROJECT_ID --name "Backend API v2"
# Set capitalization type for time tracking
alloy project update $PROJECT_ID --cap-type capex --phase app-development --amortization-months 36
project delete
alloy project delete <ID>
Examples:
# Delete a project
alloy project delete $PROJECT_ID
# Delete by key lookup
alloy project delete $(alloy project get $PROJECT_ID --format json | jq -r '.id')
project use
Set a default project context. This enables bare ticket number resolution
(e.g., alloy ticket get 42 resolves to PROJ-42).
alloy project use <KEY_OR_ID>
Accepts either a project key (like BACK) or a UUID. The project ID and key are
saved to ~/.config/alloy/credentials.toml.
Examples:
# Set default project by key
alloy project use BACK
# Set default project by UUID
alloy project use 550e8400-e29b-41d4-a716-446655440000
# Now bare numbers work everywhere
alloy ticket get 42 # resolves to BACK-42
alloy ticket list # lists tickets in BACK
ticket
Create, list, update, and manage tickets. Supports smart ID resolution.
Smart ID Resolution: Ticket commands accept three reference formats:
| Format | Example | Resolution |
|---|---|---|
| UUID | 550e8400-... | Used directly |
| KEY-NUMBER | BACK-42 | Resolved via API (/api/v1/tickets/resolve?ref=BACK-42) |
| Bare number | 42 | Requires project context; resolves as {PROJECT_KEY}-42 |
Set project context with alloy project use <KEY> to enable bare number references.
ticket create
alloy ticket create [--project <PROJECT_ID>] [--title <TITLE>] [--description <DESC>] [--priority <PRIORITY>] [--status <STATUS>] [--assignee-id <UUID>] [--reporter-id <UUID>]
| Flag | Default | Description |
|---|---|---|
--project <ID> | credentials project | Project UUID (prompted interactively if omitted) |
--title <TITLE> | — | Ticket title (prompted interactively if omitted) |
--description <DESC> | — | Detailed description |
--priority <PRIORITY> | medium | none, low, medium, high, urgent |
--status <STATUS> | backlog | backlog, todo, in_progress, in_review, done, cancelled |
--assignee-id <UUID> | — | Assignee user UUID (fuzzy selector in interactive mode) |
--reporter-id <UUID> | logged-in user | Reporter user UUID |
In interactive mode (TTY), missing fields are prompted with fuzzy selectors for projects, users, and priorities.
Examples:
# Create a ticket interactively (prompts for missing fields)
alloy ticket create
# Create a ticket non-interactively
alloy ticket create --project $PROJECT_ID --title "Fix login timeout" --priority high --status todo
# Create and capture the ticket ID
TICKET_ID=$(alloy ticket create --project $PROJECT_ID --title "Add caching" --format json | jq -r '.id')
ticket list
alloy ticket list [--project <PROJECT_ID>] [--status <STATUS>] [--priority <PRIORITY>] [--assignee-id <UUID>] [--cursor <CURSOR>] [--limit <LIMIT>]
| Flag | Default | Description |
|---|---|---|
--project <ID> | credentials project | Project UUID |
--status <STATUS> | — | Filter by status (comma-separated) |
--priority <PRIORITY> | — | Filter by priority |
--assignee-id <UUID> | — | Filter by assignee |
--cursor <CURSOR> | — | Pagination cursor |
--limit <LIMIT> | 20 | Results per page |
Examples:
# List tickets in the default project
alloy ticket list
# Filter by status
alloy ticket list --status todo,in_progress
# List high-priority tickets as JSON
alloy ticket list --priority high --format json
# Page through results
alloy ticket list --limit 10 --cursor "next_cursor_value"
ticket get
alloy ticket get <ID>
Accepts UUID, KEY-NUMBER, or bare number.
Examples:
# Get by project-qualified reference
alloy ticket get BACK-42
# Get by bare number (uses default project)
alloy ticket get 42
# Get by UUID
alloy ticket get 550e8400-e29b-41d4-a716-446655440000
ticket update
alloy ticket update <ID> [--title <TITLE>] [--description <DESC>] [--status <STATUS>] [--priority <PRIORITY>] [--assignee-id <UUID|none>] [--add-label <LABEL_ID>] [--remove-label <LABEL_ID>]
In interactive mode with no flags, a multi-select dialog prompts for which fields to update with fuzzy selectors for users and labels.
Examples:
# Move a ticket to in-progress
alloy ticket update BACK-42 --status in_progress
# Assign and add a label
alloy ticket update 42 --assignee-id $USER_ID --add-label $LABEL_ID
# Interactive update (prompts for fields to change)
alloy ticket update BACK-42
ticket batch-update
alloy ticket batch-update --ticket-ids <UUID,UUID,...> [--title <TITLE>] [--description <DESC>] [--status <STATUS>] [--priority <PRIORITY>] [--assignee-id <UUID>] [--sprint-id <UUID>]
Apply the same field updates to multiple tickets at once. At least one field flag must be provided.
| Flag | Description |
|---|---|
--ticket-ids <IDs> | Comma-separated ticket UUIDs |
--title <TITLE> | New title for all tickets |
--description <DESC> | New description for all tickets |
--status <STATUS> | New status for all tickets |
--priority <PRIORITY> | New priority (none, low, medium, high, urgent) |
--assignee-id <UUID> | Assignee UUID for all tickets |
--sprint-id <UUID> | Sprint UUID for all tickets |
Examples:
# Set all tickets to high priority
alloy ticket batch-update --ticket-ids "$ID1,$ID2" --priority high
# Move multiple tickets to Done
alloy ticket batch-update --ticket-ids "$ID1,$ID2" --status Done
ticket batch-transition
alloy ticket batch-transition --ticket-ids <UUID,UUID,...> --to-status <STATUS>
Transition multiple tickets to the same target status. Each ticket is validated against its project’s workflow. Reports successes and failures individually.
| Flag | Description |
|---|---|
--ticket-ids <IDs> | Comma-separated ticket UUIDs |
--to-status <STATUS> | Target status to transition all tickets to |
Examples:
# Transition all sprint tickets to Done
alloy ticket batch-transition --ticket-ids "$ID1,$ID2" --to-status Done
comment
Add and manage comments on tickets.
comment add
alloy comment add --ticket <TICKET_REF> --body <BODY> --author-id <UUID> [--parent-id <COMMENT_ID>]
| Flag | Description |
|---|---|
--ticket <REF> | Ticket reference (UUID, KEY-NUMBER, or bare number) |
--body <BODY> | Comment text |
--author-id <UUID> | Author’s user UUID |
--parent-id <ID> | Parent comment UUID for threading |
Examples:
# Add a comment to a ticket
alloy comment add --ticket BACK-42 --body "Deployed to staging" --author-id $USER_ID
# Reply to an existing comment (threaded)
alloy comment add --ticket 42 --body "Confirmed fixed" --author-id $USER_ID --parent-id $PARENT_COMMENT_ID
comment list
alloy comment list --ticket <TICKET_REF> [--cursor <CURSOR>] [--limit <LIMIT>]
Examples:
# List comments on a ticket
alloy comment list --ticket BACK-42
# Get comments as JSON
alloy comment list --ticket 42 --format json
comment get
alloy comment get <ID>
Examples:
# Get a specific comment
alloy comment get $COMMENT_ID
# Get comment as JSON
alloy comment get $COMMENT_ID --format json
comment update
alloy comment update <ID> --body <BODY>
Examples:
# Edit a comment
alloy comment update $COMMENT_ID --body "Updated: deployed to production"
# Update and verify
alloy comment update $COMMENT_ID --body "Fixed in v2.1" && alloy comment get $COMMENT_ID
comment delete
alloy comment delete <ID>
Examples:
# Delete a comment
alloy comment delete $COMMENT_ID
label
Manage labels for organizing tickets.
label create
alloy label create --org <ORG_ID> --name <NAME> --color <COLOR>
| Flag | Description |
|---|---|
--org <ORG_ID> | Organization UUID |
--name <NAME> | Label name (e.g. bug, feature) |
--color <COLOR> | Hex color code (e.g. #FF0000) |
Examples:
# Create a bug label
alloy label create --org $ORG_ID --name bug --color "#FF0000"
# Create a feature label
alloy label create --org $ORG_ID --name feature --color "#00FF00"
label list
alloy label list --org <ORG_ID> [--cursor <CURSOR>] [--limit <LIMIT>]
Examples:
# List all labels
alloy label list --org $ORG_ID
# Get labels as JSON
alloy label list --org $ORG_ID --format json
label get
alloy label get <ID>
Examples:
# Get label details
alloy label get $LABEL_ID
label update
alloy label update <ID> [--name <NAME>] [--color <COLOR>]
Examples:
# Change label color
alloy label update $LABEL_ID --color "#0000FF"
# Rename a label
alloy label update $LABEL_ID --name "critical-bug"
label delete
alloy label delete <ID>
Examples:
# Delete a label
alloy label delete $LABEL_ID
workflow
Define custom workflows with statuses and allowed transitions.
workflow create
alloy workflow create --org-id <ORG_ID> --name <NAME> --statuses <JSON> --transitions <JSON>
| Flag | Description |
|---|---|
--org-id <ORG_ID> | Organization UUID |
--name <NAME> | Workflow name |
--statuses <JSON> | JSON array of status definitions |
--transitions <JSON> | JSON array of allowed transitions |
Examples:
# Create a simple workflow
alloy workflow create --org-id $ORG_ID --name "Standard" \
--statuses '[{"name":"Backlog","category":"todo"},{"name":"In Progress","category":"in_progress"},{"name":"Done","category":"done"}]' \
--transitions '[{"from":"Backlog","to":"In Progress"},{"from":"In Progress","to":"Done"}]'
# Create a workflow with review step
alloy workflow create --org-id $ORG_ID --name "With Review" \
--statuses '[{"name":"Todo","category":"todo"},{"name":"Working","category":"in_progress"},{"name":"Review","category":"in_progress"},{"name":"Done","category":"done"}]' \
--transitions '[{"from":"Todo","to":"Working"},{"from":"Working","to":"Review"},{"from":"Review","to":"Done"},{"from":"Review","to":"Working"}]'
workflow list
alloy workflow list --org-id <ORG_ID> [--cursor <CURSOR>] [--limit <LIMIT>]
Examples:
# List workflows
alloy workflow list --org-id $ORG_ID
# Get workflow details as JSON
alloy workflow list --org-id $ORG_ID --format json
workflow show
alloy workflow show <ID>
Examples:
# Show workflow with statuses and transitions
alloy workflow show $WORKFLOW_ID
# Get workflow as JSON for editing
alloy workflow show $WORKFLOW_ID --format json
workflow update
alloy workflow update <ID> [--name <NAME>] [--statuses <JSON>] [--transitions <JSON>]
Examples:
# Rename a workflow
alloy workflow update $WORKFLOW_ID --name "Engineering Flow"
# Add a new status and transition
alloy workflow update $WORKFLOW_ID \
--statuses '[{"name":"Todo","category":"todo"},{"name":"In Progress","category":"in_progress"},{"name":"QA","category":"in_progress"},{"name":"Done","category":"done"}]' \
--transitions '[{"from":"Todo","to":"In Progress"},{"from":"In Progress","to":"QA"},{"from":"QA","to":"Done"}]'
workflow delete
alloy workflow delete <ID>
Examples:
# Delete a workflow
alloy workflow delete $WORKFLOW_ID
tag
Manage key-value tags on entities (projects, tickets, users, teams, time entries).
tag set
Set one or more key-value tags on an entity (upsert — creates or updates).
alloy tag set --org <ORG_ID> --entity-type <TYPE> --entity-id <ID> --tag <KEY=VALUE>...
| Flag | Description |
|---|---|
--org <ORG_ID> | Organization UUID |
--entity-type <TYPE> | Entity type: project, ticket, user, team, time_entry |
--entity-id <ID> | Entity UUID |
--tag <KEY=VALUE> | Tag key-value pair (repeatable) |
Examples:
# Tag a project with environment and team
alloy tag set --org $ORG_ID --entity-type project --entity-id $PROJECT_ID \
--tag env=production --tag team=backend
# Tag a ticket with a priority label
alloy tag set --org $ORG_ID --entity-type ticket --entity-id $TICKET_ID --tag sprint=12
tag get
Get all tags for an entity.
alloy tag get --org <ORG_ID> --entity-type <TYPE> --entity-id <ID>
| Flag | Description |
|---|---|
--org <ORG_ID> | Organization UUID |
--entity-type <TYPE> | Entity type |
--entity-id <ID> | Entity UUID |
Examples:
# Get tags for a project
alloy tag get --org $ORG_ID --entity-type project --entity-id $PROJECT_ID
# Get tags as JSON
alloy tag get --org $ORG_ID --entity-type ticket --entity-id $TICKET_ID --format json
tag delete
Delete a tag by key from an entity.
alloy tag delete --org <ORG_ID> --entity-type <TYPE> --entity-id <ID> --key <KEY>
| Flag | Description |
|---|---|
--org <ORG_ID> | Organization UUID |
--entity-type <TYPE> | Entity type |
--entity-id <ID> | Entity UUID |
--key <KEY> | Tag key to delete |
Examples:
# Remove the "env" tag from a project
alloy tag delete --org $ORG_ID --entity-type project --entity-id $PROJECT_ID --key env
tag search
Search for entities by tag key-value pair.
alloy tag search --org <ORG_ID> --key <KEY> --value <VALUE> [--cursor <CURSOR>] [--limit <LIMIT>]
| Flag | Default | Description |
|---|---|---|
--org <ORG_ID> | (required) | Organization UUID |
--key <KEY> | (required) | Tag key to search for |
--value <VALUE> | (required) | Tag value to search for |
--cursor <CURSOR> | — | Pagination cursor |
--limit <LIMIT> | 20 | Results per page |
Examples:
# Find all entities tagged env=production
alloy tag search --org $ORG_ID --key env --value production
# Search with pagination
alloy tag search --org $ORG_ID --key team --value backend --limit 10 --format json
team
Manage teams within an organization.
team create
alloy team create --name <NAME> [--description <DESC>]
| Flag | Description |
|---|---|
--name <NAME> | Team name |
--description <DESC> | Optional team description |
Examples:
# Create a team
alloy team create --name "Backend"
# Create with description
alloy team create --name "Platform" --description "Infrastructure and DevOps"
team list
alloy team list
Examples:
# List all teams
alloy team list
# Get teams as JSON
alloy team list --format json
team delete
alloy team delete <ID> [--cascade] [--dry-run]
| Flag | Description |
|---|---|
--cascade | Cascade-delete all dependents (members, projects) |
--dry-run | Preview what would be deleted (requires --cascade) |
Examples:
# Delete a team
alloy team delete $TEAM_ID
# Preview cascade delete
alloy team delete $TEAM_ID --cascade --dry-run
# Cascade delete team and all dependents
alloy team delete $TEAM_ID --cascade
project member
Manage project membership. Nested under project.
project member add
alloy project member add --project-id <PROJECT_ID> --user-id <USER_ID>
| Flag | Description |
|---|---|
--project-id <ID> | Project UUID |
--user-id <ID> | User UUID to add |
Examples:
# Add a user to a project
alloy project member add --project-id $PROJECT_ID --user-id $USER_ID
project member remove
alloy project member remove --project-id <PROJECT_ID> --user-id <USER_ID>
| Flag | Description |
|---|---|
--project-id <ID> | Project UUID |
--user-id <ID> | User UUID to remove |
Examples:
# Remove a user from a project
alloy project member remove --project-id $PROJECT_ID --user-id $USER_ID
project member list
alloy project member list --project-id <PROJECT_ID>
| Flag | Description |
|---|---|
--project-id <ID> | Project UUID |
Examples:
# List project members
alloy project member list --project-id $PROJECT_ID
# Get members as JSON
alloy project member list --project-id $PROJECT_ID --format json
report
Generate capitalization and finance reports.
report capitalization
Generate a capitalization report as JSON with optional grouping and filters.
alloy report capitalization --period <YYYY-MM> [--group-by <GROUP>] [--include-users] [--include-budget] [--team-id <ID>] [--user-id <ID>] [--cost-center-id <ID>] [--activity-type <TYPE>] [--tag <KEY:VALUE>]
| Flag | Default | Description |
|---|---|---|
--period <YYYY-MM> | (required) | Reporting month |
--group-by <GROUP> | — | Group by team or user |
--include-users | false | Include individual user breakdowns |
--include-budget | false | Include budget data |
--team-id <ID> | — | Filter by team UUID |
--user-id <ID> | — | Filter by user UUID |
--cost-center-id <ID> | — | Filter by cost center UUID |
--activity-type <TYPE> | — | Filter by activity type |
--tag <KEY:VALUE> | — | Tag filter (repeatable) |
Examples:
# Basic capitalization report
alloy report capitalization --period 2026-03
# Report grouped by team with user breakdowns
alloy report capitalization --period 2026-03 --group-by team --include-users
# Filtered report
alloy report capitalization --period 2026-03 --team-id $TEAM_ID --activity-type development
report export
Export a capitalization report as CSV to stdout.
alloy report export --period <YYYY-MM> [--team-id <ID>] [--user-id <ID>] [--cost-center-id <ID>] [--activity-type <TYPE>] [--tag <KEY:VALUE>]
| Flag | Default | Description |
|---|---|---|
--period <YYYY-MM> | (required) | Reporting month |
--team-id <ID> | — | Filter by team UUID |
--user-id <ID> | — | Filter by user UUID |
--cost-center-id <ID> | — | Filter by cost center UUID |
--activity-type <TYPE> | — | Filter by activity type |
--tag <KEY:VALUE> | — | Tag filter (repeatable) |
Examples:
# Export March report as CSV
alloy report export --period 2026-03
# Save CSV to file
alloy report export --period 2026-03 > march-2026.csv
# Export filtered by team
alloy report export --period 2026-03 --team-id $TEAM_ID
export
Export complete project data as portable JSON or SQLite. Uses human-readable keys (emails, project keys, label names) instead of UUIDs.
alloy export [--org <ORG_ID>] [--project <KEY>] [--export-format <FORMAT>] [--output <FILE>]
| Flag | Default | Description |
|---|---|---|
--org <ORG_ID> | stored org | Organization UUID (defaults to org from login) |
--project <KEY> | — | Filter to a single project by key |
--export-format <FORMAT> | json | Export format: json or sqlite |
-o, --output <FILE> | alloy-export.db | Output file path (for sqlite format) |
Examples:
# Export everything as JSON (uses stored org from login)
alloy export
# Export with explicit org ID
alloy export --org $ORG_ID
# Export a single project
alloy export --project PROJ
# Save to file
alloy export --project PROJ > proj-backup.json
# Export as SQLite database
alloy export --export-format sqlite --output alloy-export.db
import
Import data from an Alloy JSON export file, a Jira JSON export, or a Linear JSON export. Reads from a file path or stdin. Supports --dry-run to preview what would be imported without making changes. Use --from jira or --from linear to import from external tools.
alloy import [FILE] [--org <ORG_ID>] [--from <SOURCE>] [--dry-run]
| Argument/Flag | Default | Description |
|---|---|---|
[FILE] | stdin | Path to JSON export file (reads stdin if omitted) |
--org <ORG_ID> | stored org | Organization UUID (defaults to org from login) |
--from <SOURCE> | alloy | Source format: alloy (native), jira (Jira export), or linear (Linear export) |
--dry-run | — | Preview import without making changes |
Examples:
# Import from an Alloy export file
alloy import backup.json
# Preview what would be imported
alloy import backup.json --dry-run
# Import from stdin (e.g. pipe from export)
alloy export --project PROJ | alloy import
# Import with explicit org ID
alloy import backup.json --org $ORG_ID
# Import from a Jira JSON export
alloy import jira-export.json --from jira
# Preview a Jira import without making changes
alloy import jira-export.json --from jira --dry-run
# Import from a Linear JSON export
alloy import linear-export.json --from linear
# Preview a Linear import without making changes
alloy import linear-export.json --from linear --dry-run
Jira import mapping:
| Jira concept | Alloy concept | Notes |
|---|---|---|
| Projects | Projects | Key and name preserved |
| Issues | Tickets | Number extracted from issue key |
| Issue types | Labels | Mapped as type:<IssueType> labels |
| Priorities | Priorities | Blocker/Highest→Urgent, High/Major→High, etc. |
| Statuses | Workflow statuses | Grouped into a “Jira Import” workflow |
| Versions | Sprints | Released versions become Completed sprints |
| Components | Labels | Mapped to labels with a warning |
| Comments | Comments | Author and body preserved |
| Worklogs | Time entries | Duration converted from seconds to minutes |
| Labels | Labels | Preserved as-is |
| Links | — | Skipped with a warning |
| Custom fields | — | Skipped with a warning |
| Resolutions | — | Logged as warnings (status preserved) |
Linear import mapping:
| Linear concept | Alloy concept | Notes |
|---|---|---|
| Teams | Projects | Key and name preserved |
| Issues | Tickets | Number extracted from identifier |
| Priorities | Priorities | 1=Urgent, 2=High, 3=Medium, 4=Low, 0=None |
| States | Workflow statuses | Grouped into a “Linear Import” workflow |
| Cycles | Sprints | Completed cycles become Completed sprints |
| Labels | Labels | Color preserved from Linear |
| Comments | Comments | Author and body preserved |
| Users | Users | Email preserved; display name used |
| Projects | — | Skipped with a warning (teams used instead) |
| Relations | — | Skipped with a warning |
| Estimates | — | Skipped with a warning |
| Sub-issues | Tickets | Imported as top-level tickets with a warning |
board
Show the sprint board — tickets organized by workflow status columns.
alloy board <SPRINT_ID> [--format <FORMAT>]
| Argument/Flag | Description |
|---|---|
<SPRINT_ID> | Sprint UUID |
--format | Output format (json, table, plain) |
Table output shows the sprint header, then each status column with its ticket count and the tickets listed with priority, title, and assignee.
Examples:
# View the sprint board
alloy board $SPRINT_ID
# Get board as JSON for scripting
alloy board $SPRINT_ID --format json
# Pipe plain output for processing
alloy board $SPRINT_ID --format plain
burndown
Show sprint burndown chart data — daily progress with total, completed, and remaining ticket counts.
alloy burndown <SPRINT_ID> [--format <FORMAT>]
| Argument/Flag | Description |
|---|---|
<SPRINT_ID> | Sprint UUID |
--format | Output format (json, table, plain) |
Table output shows a dated table with total, completed, and remaining columns.
Examples:
# View burndown data
alloy burndown $SPRINT_ID
# Get burndown as JSON
alloy burndown $SPRINT_ID --format json
# Export for a spreadsheet
alloy burndown $SPRINT_ID --format plain > burndown.tsv
sprint
Manage sprints within projects. Sprints have a lifecycle: planning → active → completed.
Use alloy board <sprint-id> to view the sprint board and alloy burndown <sprint-id>
to view the burndown chart.
sprint create
alloy sprint create --project <PROJECT_ID> --name <NAME> --start-date <DATE> --end-date <DATE> [--goal <GOAL>]
| Flag | Description |
|---|---|
--project <ID> | Project UUID (fuzzy selector in interactive mode) |
--name <NAME> | Sprint name (e.g. Sprint 12) |
--start-date <DATE> | Start date (YYYY-MM-DD) |
--end-date <DATE> | End date (YYYY-MM-DD) |
--goal <GOAL> | Optional sprint goal |
Examples:
# Create a two-week sprint
alloy sprint create --project $PROJECT_ID --name "Sprint 12" \
--start-date 2026-03-25 --end-date 2026-04-08 \
--goal "Ship authentication and onboarding"
# Create a sprint and capture its ID
SPRINT_ID=$(alloy sprint create --project $PROJECT_ID --name "Sprint 13" \
--start-date 2026-04-08 --end-date 2026-04-22 --format json | jq -r '.id')
sprint list
alloy sprint list --project <PROJECT_ID> [--cursor <CURSOR>] [--limit <LIMIT>]
Examples:
# List sprints for a project
alloy sprint list --project $PROJECT_ID
# Get sprints as JSON
alloy sprint list --project $PROJECT_ID --format json
sprint start
Transition a sprint from planning to active.
alloy sprint start <ID>
Examples:
# Start a sprint
alloy sprint start $SPRINT_ID
# Start and verify status
alloy sprint start $SPRINT_ID && alloy sprint list --project $PROJECT_ID
sprint complete
Transition a sprint from active to completed.
alloy sprint complete <ID>
Examples:
# Complete a sprint
alloy sprint complete $SPRINT_ID
time
Log and manage time entries for capitalization tracking. Entries follow a
lifecycle: draft → submitted → approved.
time log
alloy time log [--user-id <UUID>] [--ticket <TICKET_REF>] [--project <PROJECT_ID>] [--date <DATE>] [--duration <MINUTES>] [--description <DESC>] [--activity-type <TYPE>]
| Flag | Default | Description |
|---|---|---|
--user-id <UUID> | logged-in user | User UUID |
--ticket <REF> | — | Ticket reference (prompted if missing) |
--project <ID> | from ticket | Project UUID |
--date <DATE> | today | Date in YYYY-MM-DD format |
--duration <MINUTES> | — | Duration in minutes (prompted if missing) |
--description <DESC> | — | What was done |
--activity-type <TYPE> | — | Activity category (prompted if missing) |
Activity types: Coding, Testing, CodeReview, Design, Documentation,
Meeting, Planning, DevOps, BugFix, Refactoring, Research, Support, Other
Examples:
# Log time interactively (prompts for missing fields)
alloy time log
# Log 90 minutes of coding against a ticket
alloy time log --ticket BACK-42 --duration 90 --activity-type Coding --description "Implemented auth middleware"
# Log time for yesterday
alloy time log --ticket 42 --duration 60 --activity-type CodeReview --date 2026-03-24
time list
alloy time list --ticket <TICKET_REF> [--cursor <CURSOR>] [--limit <LIMIT>]
Examples:
# List time entries for a ticket
alloy time list --ticket BACK-42
# Get time entries as JSON
alloy time list --ticket 42 --format json
time get
alloy time get <ID>
Examples:
# Get time entry details
alloy time get $TIME_ENTRY_ID
time submit
Move a time entry from draft to submitted for approval.
alloy time submit <ID>
Examples:
# Submit a time entry for approval
alloy time submit $TIME_ENTRY_ID
# Log and submit in one flow
ENTRY=$(alloy time log --ticket 42 --duration 60 --activity-type Coding --format json | jq -r '.id')
alloy time submit $ENTRY
time approve
Approve a submitted time entry (manager action).
alloy time approve <ID>
Examples:
# Approve a time entry
alloy time approve $TIME_ENTRY_ID
time report
Generate capitalization reports for a given month.
alloy time report --period <YYYY-MM> [--output <csv|json>]
| Flag | Default | Description |
|---|---|---|
--period <YYYY-MM> | (required) | Reporting month |
--output <csv|json> | csv | Report format |
Examples:
# Export March 2026 as CSV
alloy time report --period 2026-03
# Get report as JSON
alloy time report --period 2026-03 --output json
# Save CSV report to file
alloy time report --period 2026-03 > march-2026.csv
invite
Create and manage organization invitations.
invite create
alloy invite create --org-id <ORG_ID> [--email <EMAIL>] [--role <ROLE>]
| Flag | Default | Description |
|---|---|---|
--org-id <ORG_ID> | (required) | Organization UUID |
--email <EMAIL> | — | Invitee email (omit for open invite link) |
--role <ROLE> | Member | Owner, Admin, Member, Viewer |
Examples:
# Invite a specific person
alloy invite create --org-id $ORG_ID --email dev@example.com --role Member
# Create an open invite link for the team
alloy invite create --org-id $ORG_ID --role Member
# Get just the invite code for scripting
alloy invite create --org-id $ORG_ID --email new@example.com --format plain
invite list
alloy invite list --org-id <ORG_ID>
Examples:
# List pending invitations
alloy invite list --org-id $ORG_ID
# List invites as JSON
alloy invite list --org-id $ORG_ID --format json
onboard
First-run setup wizard. Creates the initial admin user, organization, and API key. Only works when no organizations exist in the system.
alloy onboard --email <EMAIL> --password <PASSWORD> --org-name <NAME> --org-slug <SLUG>
| Flag | Description |
|---|---|
--email <EMAIL> | Admin email address |
--password <PASSWORD> | Admin password (min 8 characters) |
--org-name <NAME> | Organization display name |
--org-slug <SLUG> | URL-friendly organization identifier |
Returns an API key that is shown only once — save it immediately.
Examples:
# Set up a fresh Alloy instance
alloy onboard --email admin@example.com --password mysecretpw --org-name "Acme Corp" --org-slug acme
# Capture the API key for automation
API_KEY=$(alloy onboard --email admin@example.com --password mysecretpw \
--org-name "Acme" --org-slug acme --format plain)
completions
Generate shell completion scripts.
alloy completions <SHELL>
Supported shells: bash, zsh, fish, powershell, elvish.
Examples:
# Generate Zsh completions
alloy completions zsh > ~/.zfunc/_alloy
# Generate Bash completions
alloy completions bash > /etc/bash_completion.d/alloy
usage
Print agent-optimized self-documentation (<1500 tokens). Useful for LLM agents that need to discover available commands.
alloy usage [SUBCOMMAND]
| Argument | Description |
|---|---|
SUBCOMMAND | Optional command name for detailed help |
Examples:
# Get concise overview of all commands
alloy usage
# Get detailed help for a specific command
alloy usage ticket
Credential Storage
Credentials are stored at ~/.config/alloy/credentials.toml (platform-specific
via the directories crate). The file contains:
access_token = "eyJ..."
refresh_token = "abc123..."
user_id = "550e8400-..."
email = "admin@example.com"
org_id = "660e8400-..." # set by `alloy org switch`
project_id = "770e8400-..." # set by `alloy project use`
project_key = "BACK" # set by `alloy project use`
The CLI auto-refreshes expired tokens: on a 401 response, it calls
/api/v1/auth/refresh with the stored refresh token, updates credentials on
disk, and retries the original request.
Common Workflows
Create an organization and invite your team
# First-time setup
alloy onboard --email admin@acme.com --password s3cretpw \
--org-name "Acme Corp" --org-slug acme
# Log in
alloy auth login --email admin@acme.com --password s3cretpw
# Create a project
alloy project create --org-id $ORG_ID --key BACK --name "Backend"
alloy project use BACK
# Invite team members
alloy invite create --org-id $ORG_ID --email dev1@acme.com --role Member
alloy invite create --org-id $ORG_ID --email dev2@acme.com --role Member
alloy invite create --org-id $ORG_ID --email lead@acme.com --role Admin
Sprint planning
# Create a sprint
alloy sprint create --project $PROJECT_ID --name "Sprint 12" \
--start-date 2026-03-25 --end-date 2026-04-08 \
--goal "Ship user authentication"
# Create tickets for the sprint
alloy ticket create --project $PROJECT_ID --title "Implement login endpoint" --priority high
alloy ticket create --project $PROJECT_ID --title "Add password reset flow" --priority medium
alloy ticket create --project $PROJECT_ID --title "Write auth integration tests" --priority medium
# Start the sprint
alloy sprint start $SPRINT_ID
# Check sprint status
alloy sprint list --project $PROJECT_ID
Time tracking for a week
# Set your default project
alloy project use BACK
# Log daily entries
alloy time log --ticket 42 --duration 120 --activity-type Coding --date 2026-03-24 --description "Auth middleware"
alloy time log --ticket 42 --duration 90 --activity-type Testing --date 2026-03-25 --description "Auth tests"
alloy time log --ticket 43 --duration 60 --activity-type CodeReview --date 2026-03-25 --description "PR review"
alloy time log --ticket 44 --duration 45 --activity-type Meeting --date 2026-03-26 --description "Sprint planning"
alloy time log --ticket 42 --duration 180 --activity-type Coding --date 2026-03-27 --description "Token refresh"
# Submit all entries
alloy time list --ticket 42 --format json | jq -r '.data[].id' | xargs -I{} alloy time submit {}
# Generate monthly report
alloy time report --period 2026-03
Bulk ticket updates with JSON output piping
# Close all done tickets
alloy ticket list --status done --format json | jq -r '.data[].id' | \
xargs -I{} alloy ticket update {} --status cancelled
# Reassign all tickets from one user to another
alloy ticket list --assignee-id $OLD_USER --format json | jq -r '.data[].id' | \
xargs -I{} alloy ticket update {} --assignee-id $NEW_USER
# Export all high-priority tickets
alloy ticket list --priority high --format json | jq '.data[] | {id, title, status}'
Shell Completions
Alloy can generate shell completion scripts for tab-completion of commands, subcommands, flags, and enum values.
alloy completions <SHELL>
| Argument | Values | Description |
|---|---|---|
SHELL | bash, zsh, fish | Shell to generate completions for |
Bash
Add to your ~/.bashrc:
# Generate and source completions
eval "$(alloy completions bash)"
Or install persistently:
# Create completions directory if needed
mkdir -p ~/.local/share/bash-completion/completions
# Generate the completion script
alloy completions bash > ~/.local/share/bash-completion/completions/alloy
# Reload your shell
source ~/.bashrc
Zsh
Add to your ~/.zshrc:
# Generate and source completions
eval "$(alloy completions zsh)"
Or install persistently:
# Ensure completions directory is in fpath (add to ~/.zshrc before compinit)
mkdir -p ~/.zfunc
echo 'fpath=(~/.zfunc $fpath)' >> ~/.zshrc
# Generate the completion script
alloy completions zsh > ~/.zfunc/_alloy
# Rebuild completion cache
rm -f ~/.zcompdump && compinit
Fish
# Generate and install completions
alloy completions fish > ~/.config/fish/completions/alloy.fish
Fish automatically loads completions from ~/.config/fish/completions/, so no
additional configuration is needed.
Verifying Completions
After installation, restart your shell (or source the relevant config file) and test by typing:
alloy <TAB>
You should see all available subcommands (health, project, ticket,
sprint, time, team, completions, etc.) as completion suggestions.
Alloy TUI Guide & Keybinding Cheat Sheet
The Alloy TUI is a terminal-based interface for managing projects, tickets, and sprints. It uses vim-style modal editing — if you know vim, you already know how to navigate.
Launching the TUI
# Default — connects to http://localhost:3000
cargo run --bin alloy-tui
# Custom API server
ALLOY_BASE_URL=https://api.example.com cargo run --bin alloy-tui
# Or export first
export ALLOY_BASE_URL=http://localhost:3000
cargo run --bin alloy-tui
If you have the alloy-tui binary on your PATH:
alloy-tui
ALLOY_BASE_URL=https://api.example.com alloy-tui
Configuration
| Variable | Default | Description |
|---|---|---|
ALLOY_BASE_URL | http://localhost:3000 | Alloy API server URL |
ALLOY_ORG_ID | (auto-discovered) | Organization ID override |
Auto-Discover Behavior
On startup the TUI automatically discovers your organization:
- If
ALLOY_ORG_IDis set, that value is used directly. - Otherwise, once connected to the API, the TUI calls
GET /api/v1/auth/meand extractsorg_idfrom the response. - After
org_idis resolved, projects, tickets, and labels are fetched automatically.
This means you can launch the TUI without any configuration beyond
ALLOY_BASE_URL and a valid API key — the org is detected for you.
Modes
The TUI has four modes, displayed in the status bar with distinct colors.
| Mode | Color | How to enter | How to exit | Purpose |
|---|---|---|---|---|
| Normal | Blue | Esc from any mode | — | Navigate, select, trigger actions |
| Insert | Green | i from Normal | Esc | Edit text fields |
| Command | Yellow | : from Normal | Esc or Enter | Run commands like :q, :sort |
| Search | Magenta | / from Normal | Esc | Real-time ticket search |
If you are new to vim-style modes: you start in Normal mode. Press keys to
navigate (they are not typed into a text field). Switch to Insert mode to
type text, Command mode to enter commands, or Search mode to filter
tickets. Press Esc to return to Normal.
Views
Ticket List
The default view. Shows all tickets in a table with columns for Key, Title, Status, Assignee, and Priority.
| Key | Action |
|---|---|
j / Down | Move cursor down |
k / Up | Move cursor up |
gg | Jump to first ticket |
G | Jump to last ticket |
Enter | Open ticket detail |
Tab | Cycle project filter (All → each project → All) |
/ | Enter Search mode |
n | Create new ticket (see Ticket Creation) |
l | Manage labels on selected ticket |
t | Log time against selected ticket |
d | Delete selected ticket |
D | Cascade delete (ticket + dependents) |
? | Show help overlay |
q | Quit |
Ticket Detail
Full view of a single ticket: status, priority, assignee, reporter, sprint, timestamps, description (word-wrapped), and comments.
| Key | Action |
|---|---|
j / Down | Scroll down |
k / Up | Scroll up |
gg | Scroll to top |
G | Scroll to bottom |
n | Create new ticket |
l | Manage labels on this ticket |
t | Log time against this ticket |
d | Delete this ticket |
D | Cascade delete (ticket + dependents) |
Esc / q | Return to ticket list |
Sprint Board
Horizontal kanban board showing tickets grouped by status columns.
| Key | Action |
|---|---|
h / Left | Move to previous column |
l / Right | Move to next column |
Tab | Next column |
Shift+Tab | Previous column |
j / Down | Move down within column |
k / Up | Move up within column |
gg | Jump to top of column |
G | Jump to bottom of column |
Enter | Open ticket detail |
b | Toggle burndown chart |
L | Manage labels on selected ticket |
t | Log time against selected ticket |
d | Delete selected ticket |
D | Cascade delete (ticket + dependents) |
Esc / q | Return to ticket list |
Open a sprint board with the :sprint <sprint-id> command.
Quick Actions
Available from Ticket List, Ticket Detail, and Sprint Board views.
| Key | Action | Popup behavior |
|---|---|---|
s | Change status | Select from: Backlog, Todo, InProgress, InReview, Done, Cancelled |
a | Assign ticket | Select from known assignees, or Unassign |
c | Add comment | Type comment text, Enter to submit |
p | Change priority | Select from: Urgent, High, Medium, Low, None |
n | Create ticket | Enter title and priority, Enter to submit |
l | Manage labels | Toggle labels on/off for a ticket (L on sprint board) |
t | Log time | Enter duration (minutes) and activity type |
d | Delete ticket | Confirm deletion (Cancel selected by default) |
D | Cascade delete | Preview dependents, then confirm |
Inside a popup:
| Key | Action |
|---|---|
j / Down | Move selection down |
k / Up | Move selection up |
Tab | Switch between fields (multi-field popups) |
Enter | Confirm selection/submit |
Esc | Cancel and close popup |
The comment popup accepts free-text input. Type your comment and press Enter
to submit or Esc to cancel.
Ticket Creation
Press n to open the ticket creation popup. It has two fields:
| Field | Input type | Notes |
|---|---|---|
| Title | Text | Required — popup will not submit if empty |
| Priority | Selector | Urgent, High, Medium, Low, None (default) |
Use Tab to switch between the Title and Priority fields. In the Priority
field, j/k cycle through options. Press Enter to create the ticket or
Esc to cancel.
The ticket is created in the currently selected project. The reporter is set to the authenticated user automatically.
Label Management
Press l (or L on the sprint board) to open the label popup for the
selected ticket. The popup lists all organization labels with a toggle
indicator:
✓ label-name (#COLOR)— label is attached to the ticketlabel-name (#COLOR)— label is not attached
Navigate with j/k and press Enter to toggle a label on or off.
Press Esc to close the popup.
Time Logging
Press t to open the time logging popup for the selected ticket. It has
two fields:
| Field | Input type | Notes |
|---|---|---|
| Duration | Number | Minutes (1–1440). Only digits accepted |
| Activity Type | Selector | Coding, Testing, CodeReview, Design, Architecture, PM, Requirements, Training, Maintenance, BugFixing, Documentation, Deployment, Meetings |
Use Tab to switch between fields. The default activity type is Coding.
Press Enter to submit or Esc to cancel. The time entry is logged for
today’s date against the selected ticket.
Delete
Press d to delete the selected ticket. A confirmation popup appears with
Cancel selected by default for safety. Select “Yes, delete” and press
Enter to confirm.
If the ticket has dependents (comments, etc.), the simple delete returns a
409 Conflict. Use D instead for cascade delete:
- A dry-run request previews what will be deleted (e.g., comment count).
- A confirmation popup shows “Yes, delete ticket + N comments” and “Cancel”.
- On confirmation the ticket and all dependents are removed.
After deletion the ticket is removed from the local list and the cursor adjusts automatically.
Burndown Chart
When viewing a sprint board, a burndown chart is displayed at the bottom showing sprint progress over time. The chart includes:
- Sprint date range (start to end)
- Total tickets vs completed/remaining count
- Completion percentage
- Progress sparkline
Press b to toggle the burndown chart on or off. It is shown by default
when opening a sprint board.
Sprint Management
Sprint operations are available via command mode (:sprint).
| Command | Description |
|---|---|
:sprint <id> | Open sprint board for the given sprint ID |
:sprint list | List all sprints in the current project |
:sprint create <name> <start> <end> | Create a new sprint (dates: YYYY-MM-DD) |
:sprint start <sprint-id> | Start a planned sprint |
:sprint complete <sprint-id> | Complete an active sprint |
Examples:
:sprint list
:sprint create Sprint-4 2026-04-01 2026-04-14
:sprint start abc123-def456
:sprint complete abc123-def456
:sprint abc123-def456
Command Mode
Press : to enter Command mode. Type a command and press Enter to execute.
| Command | Description |
|---|---|
:q / :quit | Exit the application |
:filter status=X | Filter tickets by status (e.g., :filter status=Todo) |
:filter | Clear status filter |
:sort field:dir | Sort tickets (e.g., :sort priority:desc) |
:sort | Clear sort, return to default order |
:sprint <id> | Open sprint board for given sprint ID |
:sprint list | List sprints in current project |
:sprint create <name> <start> <end> | Create a new sprint |
:sprint start <id> | Start a planned sprint |
:sprint complete <id> | Complete an active sprint |
:project <key> | Switch to a specific project by key |
:project | Clear project filter (show all) |
Sort fields: priority, status, title, assignee
Sort directions: asc (default), desc
Command mode helpers:
| Key | Action |
|---|---|
Tab | Auto-complete command name |
Up | Previous command from history |
Down | Next command from history |
Backspace | Delete character (empty → Normal mode) |
Esc | Cancel, return to Normal mode |
Search
Press / to enter Search mode. Type to filter tickets in real time.
- Matches against ticket key, title, and status (case-insensitive)
- Press
Escto stop searching (matches remain highlighted) - Press
nfor next match,Nfor previous match (in Normal mode) - Filtering respects active project and status filters
Complete Keybinding Reference
Normal Mode — Global
| Key | Action |
|---|---|
: | Enter Command mode |
/ | Enter Search mode |
? | Show help overlay |
q | Quit application |
Esc | Return to Normal mode / close popup |
Normal Mode — Navigation
| Key | Action |
|---|---|
j / Down | Move down / scroll |
k / Up | Move up / scroll |
gg | Jump to top |
G | Jump to bottom |
h / Left | Previous column (sprint board) |
l / Right | Next column (sprint board) |
Tab | Next column / cycle project filter |
Shift+Tab | Previous column (sprint board) |
Enter | Open ticket detail |
Normal Mode — Actions
| Key | Action | Views |
|---|---|---|
s | Change status | List, Detail, Board |
a | Assign ticket | List, Detail, Board |
c | Add comment | List, Detail, Board |
p | Change priority | List, Detail, Board |
n | Create ticket | List, Detail |
l | Manage labels | List, Detail |
L | Manage labels | Sprint Board |
t | Log time | List, Detail, Board |
d | Delete ticket | List, Detail, Board |
D | Cascade delete | List, Detail, Board |
b | Toggle burndown | Sprint Board |
Search Mode
| Key | Action |
|---|---|
| any | Appended to search query |
Esc | Exit search (matches highlighted) |
n | Next match (after exiting search) |
N | Previous match |
Popup Controls
| Key | Action |
|---|---|
j / Down | Move selection down |
k / Up | Move selection up |
Tab | Switch field (multi-field popups) |
Enter | Confirm / submit |
Esc | Cancel / close |
Backspace | Delete character (text fields) |
TUI Layout
┌──────────────────────────────────────────────────────────────────┐
│ │
│ Main Content Area │
│ │
│ Ticket List ───────────────────────────────────────────────── │
│ ┌──────────┬────────────────────┬────────────┬──────────────┐ │
│ │ Key │ Title │ Status │ Priority │ │
│ ├──────────┼────────────────────┼────────────┼──────────────┤ │
│ │ PROJ-1 │ Fix login bug │ InProgress │ High │ │
│ │ PROJ-2 │ Add dark mode │ Todo │ Medium │ │
│ │ PROJ-3 │ Update docs │ Done │ Low │ │
│ └──────────┴────────────────────┴────────────┴──────────────┘ │
│ │
│ — or — │
│ │
│ Ticket Detail ─────────────────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Status: InProgress Priority: High │ │
│ │ Assignee: alice Reporter: bob │ │
│ │ Sprint: Sprint 3 Created: 2026-03-01 │ │
│ │ │ │
│ │ Description │ │
│ │ ──────────────────────────────────── │ │
│ │ [scrollable content with word wrapping] │ │
│ │ │ │
│ │ Comments │ │
│ │ ──────────────────────────────────── │ │
│ │ alice (2026-03-02): Looks good to me │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ — or — │
│ │
│ Sprint Board (kanban) ─────────────────────────────────────── │
│ ┌───────────────┬───────────────┬───────────────┐ │
│ │ Todo (3) │ InProgress (2)│ Done (4) │ │
│ ├───────────────┼───────────────┼───────────────┤ │
│ │ PROJ-1: Fix │ PROJ-3: API │ PROJ-2: Docs │ │
│ │ PROJ-4: Auth │ PROJ-5: Test │ PROJ-6: Setup │ │
│ │ PROJ-7: UI │ │ PROJ-8: CI │ │
│ │ │ │ PROJ-9: Deps │ │
│ └───────────────┴───────────────┴───────────────┘ │
│ ┌────────────── Burndown ──────────────────────┐ │
│ │ Sprint 3 (2026-03-01 → 2026-03-14) 67% │ │
│ │ Total: 9 Done: 6 Remaining: 3 │ │
│ │ ▁▂▃▄▅▆▇█ progress sparkline │ │
│ └──────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [NORMAL] Connected │ [PROJ] 2/15 Status Bar │
└──────────────────────────────────────────────────────────────────┘
The status bar shows:
- Mode indicator — current mode with colored background
- Connection status — whether the API is reachable
- Context info — varies by view:
- Ticket List: project key and cursor position (e.g.,
[PROJ] 5/23) - Sprint Board: sprint name, date range, progress, and board position
- Command/Search mode: the command or search buffer being typed
- Ticket List: project key and cursor position (e.g.,
API Reference
Alloy exposes a REST API at http://localhost:3000 (default).
When TLS is configured, the base URL becomes https://your-domain.com.
All examples use curl with shell variables you can set once and reuse.
Common Setup
export BASE_URL="http://localhost:3000" # or https://your-domain.com with TLS
export TOKEN="<your-access-token>"
export ORG_ID="<your-org-uuid>"
export PROJECT_ID="<your-project-uuid>"
Content Type
All request bodies are JSON. Set the header on every mutating request:
Content-Type: application/json
Authentication
Most endpoints require a Bearer token or API key in the Authorization header:
Authorization: Bearer $TOKEN
Public endpoints (no auth required): GET /health, POST /api/v1/auth/register,
POST /api/v1/auth/login, GET /api/v1/onboard, POST /api/v1/onboard.
Roles and Permissions
Every organization member has one of five roles, listed from most to least privileged:
| Role | Description |
|---|---|
| Owner | Full control. Created automatically for the org creator. |
| Admin | Manage workflows, teams, invites, delete resources. |
| Member | Create and update projects, tickets, sprints, time entries. |
| Reporter | Create tickets and comments. Limited to assigned projects. |
| Viewer | Read-only access to all resources. |
Permission Matrix
The table below shows the minimum role required for each operation. Any higher role also has access (e.g. Admin can do everything Member can). Read/list operations are available to all authenticated roles unless otherwise noted.
| Operation | Minimum Role |
|---|---|
| Orgs | |
| Create / update org | Owner |
| Invites | |
| Create / revoke invite | Admin |
| List invites | Any authenticated |
| Workflows | |
| Create / update / delete workflow | Admin |
| Teams | |
| Delete team | Admin |
| Projects | |
| Create / update project | Member |
| Delete project | Admin |
| Project Members | |
| Add / remove project member | Member |
| List project members | Any authenticated |
| Tickets | |
| Create ticket | Reporter (must be assigned to the project) |
| Update / transition ticket | Member |
| Delete ticket | Admin |
| Comments | |
| Create comment | Reporter |
| Update / delete comment | Author or Admin |
| Sprints | |
| Create / update / start / complete sprint | Member |
| Delete sprint | Admin |
| Time Entries | |
| Create / update / delete / submit time entry | Member |
| Approve time entry | Admin |
| Labor Rates | |
| All labor rate operations | Admin |
Project Membership (Reporter Scoping)
Reporters only see projects they are explicitly assigned to. When a Reporter calls
GET /api/v1/projects, only assigned projects are returned. Calling
GET /api/v1/projects/{id} or creating a ticket in an unassigned project returns 403.
Error Responses
All errors return a JSON body with this shape:
{
"error": {
"code": "not_found",
"message": "Ticket not found",
"details": []
}
}
| Status | Meaning |
|---|---|
| 401 | Missing or invalid token |
| 403 | Insufficient permissions (see Roles and Permissions) |
| 404 | Resource not found |
| 409 | Conflict — resource has dependents or duplicate key (see Delete Guards) |
| 422 | Validation error (details array lists each field) |
| 500 | Internal server error |
403 Forbidden
Returned when the authenticated user’s role is insufficient for the operation.
{
"error": {
"code": "FORBIDDEN",
"message": "requires Admin role or above",
"details": []
}
}
409 Conflict (Delete Guards)
Returned when deleting a resource that has dependents and cascade is not set.
The details array lists each dependent type and its count.
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete project with existing dependents",
"details": ["tickets: 5", "sprints: 2"]
}
}
All DELETE endpoints that enforce delete guards accept these query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all dependents recursively |
dry_run | boolean | false | With cascade=true, return dependency counts without deleting |
A cascade dry-run (?cascade=true&dry_run=true) returns 200 OK with a body listing what
would be deleted. See each DELETE endpoint for its specific response shape.
Pagination
List endpoints use cursor-based pagination. Pass cursor and limit as query
parameters. Responses include:
{
"items": [],
"next_cursor": "abc123",
"has_more": true
}
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
Auth
Register
Create a new user account.
POST /api/v1/auth/register — no auth required
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Valid email address |
password | string | yes | Minimum 8 characters |
display_name | string | yes | 1–255 characters |
invite_code | string | no | Required when registration is invite-only |
curl -s "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "changeme123",
"display_name": "Alice"
}' | jq .
{
"access_token": "eyJ...",
"refresh_token": "def...",
"user_id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice"
}
Login
Authenticate an existing user.
POST /api/v1/auth/login — no auth required
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Email address |
password | string | yes | Password |
curl -s "$BASE_URL/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "changeme123"
}' | jq .
{
"access_token": "eyJ...",
"refresh_token": "def...",
"user_id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice"
}
Refresh Token
Exchange a refresh token for new tokens.
POST /api/v1/auth/refresh
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token | string | yes | Refresh token from login/register |
curl -s "$BASE_URL/api/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq .
{
"access_token": "eyJ...",
"refresh_token": "ghi...",
"user_id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice"
}
Logout
Revoke a refresh token.
POST /api/v1/auth/logout
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token | string | yes | Token to revoke |
curl -s -X POST "$BASE_URL/api/v1/auth/logout" \
-H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"
Returns 204 No Content on success.
Get Current User
Return the authenticated user’s profile and permissions.
GET /api/v1/auth/me — requires auth
curl -s "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"user_id": "550e8400-...",
"org_id": "660e8400-...",
"email": "alice@example.com",
"role": "Owner",
"scopes": "...",
"allowed_project_ids": "..."
}
Create API Key
Generate a long-lived API key for programmatic access.
POST /api/v1/api-keys — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Key name, 1–255 characters |
scopes | string[] | no | ["read", "write"] (default) or ["admin"] |
project_ids | string[] | no | Restrict key to specific projects |
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "CI Key", "scopes": ["read", "write"]}' | jq .
{
"id": "770e8400-...",
"name": "CI Key",
"key": "alloy_live_abc123...",
"key_prefix": "alloy_live_...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-28-...",
"expires_at": null
}
List API Keys
GET /api/v1/api-keys — requires auth
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "770e8400-...",
"name": "CI Key",
"key_prefix": "alloy_live_...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-28-...",
"last_used_at": null,
"expires_at": null
}
]
Delete API Key
DELETE /api/v1/api-keys/{id} — requires auth
curl -s -X DELETE "$BASE_URL/api/v1/api-keys/$API_KEY_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content.
Onboarding
Check Onboarding Status
GET /api/v1/onboard — no auth required
curl -s "$BASE_URL/api/v1/onboard" | jq .
Returns {"needs_onboarding": true} when no users exist, or {"needs_onboarding": false} otherwise.
Run Onboarding
Create the first organization and admin user. Only available when no orgs exist.
POST /api/v1/onboard — no auth required
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Admin email |
password | string | yes | Minimum 8 characters |
org_name | string | yes | Organization name, 1–255 characters |
org_slug | string | yes | Organization slug, 1–100 characters |
curl -s "$BASE_URL/api/v1/onboard" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "changeme123",
"org_name": "Acme Corp",
"org_slug": "acme"
}' | jq .
Returns org_id, user_id, email, api_key, and api_key_prefix on success.
Returns 409 Conflict if onboarding was already completed.
Orgs
Create Organization
POST /api/v1/orgs — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | 1–255 characters |
slug | string | yes | 1–100 characters |
curl -s "$BASE_URL/api/v1/orgs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Corp", "slug": "acme"}' | jq .
{
"id": "660e8400-...",
"name": "Acme Corp",
"slug": "acme",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Organizations
GET /api/v1/orgs — requires auth
curl -s "$BASE_URL/api/v1/orgs" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "660e8400-...",
"name": "...",
"slug": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
]
}
Add Member to Organization
POST /api/v1/orgs/{id}/members — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | UUID of user to add |
role | string | no | owner, admin, member (default), or viewer |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"user_id\": \"$USER_ID\", \"role\": \"member\"}" | jq .
Returns the membership object with user_id, org_id, role, and joined_at.
Returns 409 Conflict if the user is already a member.
List Organization Members
GET /api/v1/orgs/{id}/members — requires auth
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"user_id": "550e8400-...",
"display_name": "Alice",
"email": "alice@example.com",
"role": "Owner",
"joined_at": "2026-03-28-..."
}
]
}
Create Invite
Generate an invite link to join an organization.
POST /api/v1/orgs/{org_id}/invites — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
email | string | no | Email to invite |
role | string | no | Role to assign (default: member) |
created_by | string | no | (deprecated, ignored) Derived from auth token |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "role": "member"}' | jq .
Returns the invite object with id, invite_code, invite_link, email, role, and expires_at.
List Invites
GET /api/v1/orgs/{org_id}/invites — requires auth
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns an array of invite objects for the organization.
Revoke Invite
DELETE /api/v1/orgs/{org_id}/invites/{id} — requires auth
curl -s -X DELETE "$BASE_URL/api/v1/orgs/$ORG_ID/invites/$INVITE_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content.
Projects
Guide: Projects and Tickets
Create Project
POST /api/v1/projects — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
key | string | yes | Short project key, 1–10 chars (e.g. PROJ) |
name | string | yes | 1–255 characters |
description | string | no | Project description |
team_id | string | no | Team UUID |
budget_cents | integer | no | Budget amount in cents |
budget_period | string | no | Budget period: Monthly, Quarterly, Yearly, or Fixed |
curl -s "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"PROJ\",
\"name\": \"My Project\",
\"description\": \"A sample project\"
}" | jq .
{
"id": "990e8400-...",
"org_id": "660e8400-...",
"team_id": null,
"workflow_id": null,
"key": "PROJ",
"name": "My Project",
"description": "A sample project",
"ticket_counter": 0,
"capitalization_type": null,
"development_phase": null,
"cost_center_id": null,
"amortization_months": null,
"budget_cents": null,
"budget_period": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Projects
GET /api/v1/projects?org_id={org_id} — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "990e8400-...",
"org_id": "660e8400-...",
"key": "PROJ",
"name": "My Project",
"description": "A sample project",
"ticket_counter": 0,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
],
"next_cursor": null,
"has_more": false
}
Get Project by ID
GET /api/v1/projects/{id} — requires auth
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a single ProjectResponse.
Get Project by Key
GET /api/v1/projects/key/{key} — requires auth
| Parameter | Type | Required | Description |
|---|---|---|---|
org_id | string (query) | no | Organization ID to scope the lookup. Defaults to auth token’s org. |
curl -s "$BASE_URL/api/v1/projects/key/PROJ?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a single ProjectResponse.
Update Project
PATCH /api/v1/projects/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New name |
description | string | null | no | New description or null to clear |
team_id | string | null | no | New team or null to unset |
capitalization_type | string | null | no | Capitalization type |
development_phase | string | null | no | Development phase |
cost_center_id | string | null | no | Cost center ID |
amortization_months | integer | null | no | Amortization months |
budget_cents | integer | null | no | Budget amount in cents, or null to clear |
budget_period | string | null | no | Monthly, Quarterly, Yearly, Fixed, or null to clear |
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Renamed Project"}' | jq .
Returns the updated ProjectResponse.
Delete Project
DELETE /api/v1/projects/{id} — requires Admin
Returns 204 No Content if the project has no dependents.
Returns 409 Conflict if the project has tickets or sprints. Use ?cascade=true to delete
all dependents, or ?cascade=true&dry_run=true to preview what would be deleted.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all tickets, sprints, and comments |
dry_run | boolean | false | With cascade, return counts without deleting |
Cascade dry-run response (?cascade=true&dry_run=true):
{
"project_id": "...",
"dependents": {
"tickets": 5,
"sprints": 2,
"comments": 12
}
}
409 response (no cascade, has dependents):
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete project with existing dependents",
"details": ["tickets: 5", "sprints: 2"]
}
}
curl -s -X DELETE "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
Project Members
Manage which users are assigned to a project. Reporters can only access projects they are members of. Other roles (Member and above) see all projects regardless of membership.
Add Project Member
POST /api/v1/projects/{project_id}/members — requires Member
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User UUID to add |
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
Returns 201 Created.
List Project Members
GET /api/v1/projects/{project_id}/members — requires auth
Returns an array of project membership objects:
[
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
]
Remove Project Member
DELETE /api/v1/projects/{project_id}/members/{user_id} — requires Member
Returns 204 No Content.
Tickets
Guides: Projects and Tickets | Workflows and Statuses
Create Ticket
POST /api/v1/projects/{project_id}/tickets — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | 1–500 characters |
description | string | no | Ticket description |
status | string | no | Backlog (default), Todo, InProgress, InReview, Done, Cancelled |
priority | string | no | None (default), Low, Medium, High, Critical |
assignee_id | string | no | User UUID |
reporter_id | string | no | (deprecated, ignored) Derived from auth token |
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Fix login bug\",
\"description\": \"Users can't log in with SSO\",
\"priority\": \"High\"
}" | jq .
{
"id": "aa0e8400-...",
"project_id": "990e8400-...",
"ticket_number": 1,
"title": "Fix login bug",
"description": "Users can't log in with SSO",
"status": "Backlog",
"priority": "High",
"assignee_id": null,
"reporter_id": "550e8400-...",
"sprint_id": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Tickets
GET /api/v1/projects/{project_id}/tickets — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
status | string | no | Filter by status |
priority | string | no | Filter by priority |
assignee_id | string | no | Filter by assignee |
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?status=Backlog" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "aa0e8400-...",
"project_id": "990e8400-...",
"ticket_number": 1,
"title": "Fix login bug",
"status": "Backlog",
"priority": "High",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
],
"next_cursor": null,
"has_more": false
}
Get Ticket
GET /api/v1/tickets/{id} — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a single TicketResponse.
Resolve Ticket Reference
Look up a ticket by UUID, key-number (e.g. PROJ-42), or bare number.
GET /api/v1/tickets/resolve?ref={ref} — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
ref | string | yes | UUID, KEY-NUMBER, or bare number |
project_id | string | no | Required when using bare number |
org_id | string | no | Organization ID to scope key lookup. Defaults to auth token’s org. |
curl -s "$BASE_URL/api/v1/tickets/resolve?ref=PROJ-1&org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"ticket": {
"id": "aa0e8400-...",
"ticket_number": 1,
"title": "Fix login bug"
},
"suggestions": []
}
Update Ticket
PATCH /api/v1/tickets/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | New title |
description | string | null | no | New description or null |
status | string | no | New status |
priority | string | no | New priority |
assignee_id | string | null | no | New assignee or null |
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "InProgress", "priority": "Critical"}' | jq .
Returns the updated TicketResponse.
Delete Ticket
DELETE /api/v1/tickets/{id} — requires Admin
Returns 204 No Content if the ticket has no dependents.
Returns 409 Conflict if the ticket has comments. Use ?cascade=true to delete
all dependents, or ?cascade=true&dry_run=true to preview.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all comments |
dry_run | boolean | false | With cascade, return counts without deleting |
Cascade dry-run response (?cascade=true&dry_run=true):
{
"ticket_id": "...",
"dependents": {
"comments": 3
}
}
409 response (no cascade, has dependents):
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete ticket with existing dependents",
"details": ["comments: 3"]
}
}
Batch Update Tickets
Apply the same field updates to multiple tickets at once. Accepts an array of ticket IDs and the fields to update. Returns the count of updated tickets. Requires Member role.
POST /api/v1/tickets/batch-update — requires Member
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to update |
title | string | no | New title for all tickets |
description | string|null | no | New description (null to clear) |
status | string | no | New status |
priority | string | no | New priority |
assignee_id | string|null | no | Assignee UUID (null to clear) |
sprint_id | string|null | no | Sprint UUID (null to clear) |
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-update" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_ids":["'"$TICKET_ID"'"],"status":"Done"}'
{
"updated": 1
}
Batch Delete Tickets
Delete multiple tickets at once. Supports cascade deletion (removes comments) and dry-run
mode (reports what would happen without deleting). Tickets with dependents are blocked
unless cascade is true. Requires Admin role.
POST /api/v1/tickets/batch-delete — requires Admin
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to delete |
cascade | boolean | no | If true, delete dependents (comments) too. Default: false |
dry_run | boolean | no | If true, report what would happen without deleting. Default: false |
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-delete" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_ids":["'"$TICKET_ID"'"],"cascade":true,"dry_run":true}'
{
"deleted": ["..."],
"blocked": []
}
When tickets have dependents and cascade is false, they appear in blocked:
{
"deleted": [],
"blocked": [
{
"id": "...",
"dependents": {
"comments": 3
}
}
]
}
Transition Ticket Status
Transition a ticket through a workflow. Validates the transition against the workflow’s rules and enforcement mode (see Enforcement Modes).
POST /api/v1/tickets/{id}/transition — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
to_status | string | yes | Target status |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "InReview"}' | jq .
Returns the updated TicketResponse.
Get Available Transitions
GET /api/v1/tickets/{id}/available-transitions — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/available-transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
Get Ticket Activity
GET /api/v1/tickets/{ticket_id}/activity — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/activity" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"type": "...",
"actor": "550e8400-...",
"timestamp": "2026-03-28-...",
"payload": {
"audit_log_id": "...",
"action": "...",
"entity_type": "...",
"changes": [
{
"field": "...",
"old": "...",
"new": "..."
}
]
}
}
],
"next_cursor": null,
"has_more": false
}
Comments
Guide: Projects and Tickets
Create Comment
POST /api/v1/tickets/{ticket_id}/comments — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | Comment text (min 1 char) |
author_id | string | no | (deprecated, ignored) Derived from auth token |
parent_comment_id | string | no | Parent comment UUID for replies |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"body\": \"This needs investigation.\"
}" | jq .
{
"id": "bb0e8400-...",
"ticket_id": "aa0e8400-...",
"author_id": "550e8400-...",
"body": "This needs investigation.",
"parent_comment_id": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Comments
GET /api/v1/tickets/{ticket_id}/comments — requires auth
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "bb0e8400-...",
"ticket_id": "aa0e8400-...",
"author_id": "550e8400-...",
"body": "This needs investigation.",
"parent_comment_id": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
],
"next_cursor": null,
"has_more": false
}
Get Comment
GET /api/v1/comments/{id} — requires auth
curl -s "$BASE_URL/api/v1/comments/$COMMENT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "bb0e8400-...",
"ticket_id": "aa0e8400-...",
"author_id": "550e8400-...",
"body": "This needs investigation.",
"parent_comment_id": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Update Comment
PATCH /api/v1/comments/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | Updated comment text |
curl -s -X PATCH "$BASE_URL/api/v1/comments/$COMMENT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"body": "Updated comment text."}' | jq .
{
"id": "bb0e8400-...",
"ticket_id": "aa0e8400-...",
"author_id": "550e8400-...",
"body": "Updated comment text.",
"parent_comment_id": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Delete Comment
DELETE /api/v1/comments/{id} — requires Author or Admin
Only the comment author or an Admin+ can delete a comment. Returns 403 if another
non-admin user attempts to delete.
curl -s -X DELETE "$BASE_URL/api/v1/comments/$COMMENT_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content.
Labels
Create Label
POST /api/v1/orgs/{org_id}/labels — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Label name (min 1 char) |
color | string | yes | Hex color, 4–7 chars (e.g. #FF5733) |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "bug", "color": "#FF0000"}' | jq .
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "bug",
"color": "#FF0000",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Labels
GET /api/v1/orgs/{org_id}/labels — requires auth
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "bug",
"color": "#FF0000",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
],
"next_cursor": null,
"has_more": false
}
Get Label
GET /api/v1/labels/{id} — requires auth
curl -s "$BASE_URL/api/v1/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "bug",
"color": "#FF0000",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Update Label
PATCH /api/v1/labels/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New label name |
color | string | no | New color |
curl -s -X PATCH "$BASE_URL/api/v1/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"color": "#00FF00"}' | jq .
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "bug",
"color": "#00FF00",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Batch Assign Label
Assign a label to multiple tickets at once. The label is applied to each ticket in the list. Idempotent — assigning a label that is already present has no effect. Requires Member role.
POST /api/v1/labels/{id}/batch-assign — requires Member
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to apply the label to |
curl -s -X POST "$BASE_URL/api/v1/labels/$LABEL_ID/batch-assign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_ids":["'"$TICKET_ID"'"]}'
{
"assigned": 1
}
Delete Label
DELETE /api/v1/labels/{id} — requires auth
Returns 204 No Content if the label has no dependents.
Returns 409 Conflict if tickets are using this label. Use ?cascade=true to remove
all ticket-label associations, or ?cascade=true&dry_run=true to preview.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Remove all ticket-label associations |
dry_run | boolean | false | With cascade, return counts without deleting |
Cascade dry-run response (?cascade=true&dry_run=true):
{
"label_id": "...",
"dependents": {
"tickets": 4
}
}
curl -s -X DELETE "$BASE_URL/api/v1/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
Set Ticket Labels
Replace all labels on a ticket.
POST /api/v1/tickets/{ticket_id}/labels — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
label_ids | string[] | yes | Label UUIDs to set |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"label_ids\": [\"$LABEL_ID\"]}" | jq .
[
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "...",
"color": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
]
List Ticket Labels
GET /api/v1/tickets/{ticket_id}/labels — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "...",
"color": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
]
Add Label to Ticket
POST /api/v1/tickets/{ticket_id}/labels/{label_id} — requires auth
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"name": "...",
"color": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
]
Remove Label from Ticket
DELETE /api/v1/tickets/{ticket_id}/labels/{label_id} — requires auth
curl -s -X DELETE "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content.
Sprints
Guide: Sprints and Boards
Create Sprint
POST /api/v1/projects/{project_id}/sprints — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Sprint name (min 1 char) |
goal | string | no | Sprint goal |
start_date | string | yes | ISO 8601 date |
end_date | string | yes | ISO 8601 date |
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Sprint 1",
"goal": "Complete MVP",
"start_date": "2026-03-28",
"end_date": "2026-04-11"
}' | jq .
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "Sprint 1",
"goal": "Complete MVP",
"start_date": "2026-03-28",
"end_date": "2026-04-11",
"status": "Planned",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Sprints
GET /api/v1/projects/{project_id}/sprints — requires auth
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "Sprint 1",
"goal": "Complete MVP",
"start_date": "2026-03-28",
"end_date": "2026-04-11",
"status": "Planned",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
],
"next_cursor": null,
"has_more": false
}
Get Sprint
GET /api/v1/sprints/{id} — requires auth
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "Sprint 1",
"goal": "Complete MVP",
"start_date": "2026-03-28",
"end_date": "2026-04-11",
"status": "Planned",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Update Sprint
PATCH /api/v1/sprints/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New sprint name |
goal | string | no | New sprint goal (pass null to clear) |
start_date | string | no | New start date |
end_date | string | no | New end date |
curl -s -X PATCH "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal": "Ship auth and tickets"}' | jq .
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "Sprint 1",
"goal": "Ship auth and tickets",
"start_date": "2026-03-28",
"end_date": "2026-04-11",
"status": "Planned",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Start Sprint
Transition a sprint from Planned to Active.
POST /api/v1/sprints/{id}/start — requires auth
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "...",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Complete Sprint
Transition a sprint from Active to Completed.
POST /api/v1/sprints/{id}/complete — requires auth
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/complete" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "...",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Batch Assign Tickets to Sprint
Assign multiple tickets to a sprint at once. Requires Member role.
POST /api/v1/sprints/{id}/batch-assign — requires Member
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to assign to the sprint |
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/batch-assign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_ids":["'"$TICKET_ID"'"]}'
{
"assigned": 1
}
Delete Sprint
DELETE /api/v1/sprints/{id} — requires Admin
Returns 204 No Content if the sprint has no dependents.
Returns 409 Conflict if the sprint has tickets. Use ?cascade=true to delete
all dependents, or ?cascade=true&dry_run=true to preview.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all tickets in the sprint |
dry_run | boolean | false | With cascade, return counts without deleting |
Cascade dry-run response (?cascade=true&dry_run=true):
{
"sprint_id": "...",
"dependents": {
"tickets": 8
}
}
409 response (no cascade, has dependents):
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete sprint with existing dependents",
"details": ["tickets: 8"]
}
}
Get Sprint Board
GET /api/v1/sprints/{id}/board — requires auth
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/board" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint": {
"id": "dd0e8400-...",
"project_id": "990e8400-...",
"name": "...",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
},
"columns": [
{
"status": "...",
"tickets": []
},
{
"status": "...",
"tickets": []
},
{
"status": "...",
"tickets": []
}
]
}
Get Sprint Burndown
GET /api/v1/sprints/{id}/burndown — requires auth
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/burndown" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns sprint_id and data array with daily entries containing date, total_tickets, completed_tickets, and remaining_tickets.
Workflows
Guide: Workflows and Statuses
Ticket statuses are workflow-defined — there are no hard-coded statuses. Each workflow
declares its own set of statuses with categories (todo, in_progress, done, cancelled)
and the allowed transitions between them. Every organization gets a “Default” workflow on
creation. Projects can be assigned a workflow via PATCH /api/v1/projects/{id}.
Enforcement Modes
Workflows have an enforcement field that controls what happens when a ticket transition
violates the workflow’s transition rules:
| Mode | Behavior |
|---|---|
none | Any transition is allowed regardless of workflow rules (default) |
warn | Invalid transitions are allowed but the response includes a warning field |
strict | Invalid transitions are rejected with 422 Validation Error |
When enforcement is strict and a transition is not allowed, the API returns:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "transition from 'Coding' to 'Shipped' is not allowed by workflow 'Kanban'",
"details": []
}
}
When enforcement is warn and a transition is not allowed, the transition succeeds but
the response includes a warning:
{
"ticket": { "id": "...", "status": "Done" },
"warning": "transition from 'Todo' to 'Done' is not allowed by workflow 'Simple Flow', but enforcement is set to warn"
}
Create Workflow
POST /api/v1/orgs/{org_id}/workflows — requires Admin
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Workflow name (min 1 char) |
statuses | object[] | yes | Status definitions |
statuses[].name | string | yes | Status name |
statuses[].category | string | yes | todo, in_progress, done, or cancelled |
transitions | object[] | yes | Allowed transitions |
transitions[].from | string | yes | Source status name |
transitions[].to | string | yes | Target status name |
enforcement | string | no | none (default), warn, or strict |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Simple Flow",
"statuses": [
{"name": "Todo", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Todo", "to": "InProgress"},
{"from": "InProgress", "to": "Done"}
]
}' | jq .
{
"id": "ee0e8400-...",
"org_id": "660e8400-...",
"name": "Simple Flow",
"statuses": [
{"name": "Todo", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Todo", "to": "InProgress"},
{"from": "InProgress", "to": "Done"}
],
"enforcement": "none",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Workflows
GET /api/v1/orgs/{org_id}/workflows — requires auth
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"name": "...",
"statuses": [
{"name": "...", "category": "..."}
],
"transitions": [
{"from": "...", "to": "..."}
],
"enforcement": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Get Workflow
GET /api/v1/workflows/{id} — requires auth
curl -s "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "ee0e8400-...",
"org_id": "660e8400-...",
"name": "Simple Flow",
"statuses": [
{"name": "Todo", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Todo", "to": "InProgress"},
{"from": "InProgress", "to": "Done"}
],
"enforcement": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Update Workflow
PATCH /api/v1/workflows/{id} — requires Admin
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New workflow name |
statuses | object[] | no | Replacement status definitions |
transitions | object[] | no | Replacement transition rules |
enforcement | string | no | none, warn, or strict |
curl -s -X PATCH "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Updated Flow"}' | jq .
{
"id": "ee0e8400-...",
"org_id": "660e8400-...",
"name": "Updated Flow",
"statuses": [
{"name": "Todo", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Todo", "to": "InProgress"},
{"from": "InProgress", "to": "Done"}
],
"enforcement": "...",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Delete Workflow
DELETE /api/v1/workflows/{id} — requires Admin
Returns 204 No Content if the workflow has no dependents.
Returns 409 Conflict if any projects are using this workflow:
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete workflow with existing dependents",
"details": ["projects: 3"]
}
}
Note: Unlike other DELETE endpoints, workflow deletion does not support
cascadeordry_runparameters. Unassign the workflow from all projects before deleting.
curl -s -X DELETE "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
Transition Ticket
Move a ticket to a new status. If a workflow is assigned to the project, the transition is validated against the workflow’s rules and enforcement mode.
POST /api/v1/tickets/{id}/transition — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
to_status | string | yes | Target status name |
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "InProgress"}' | jq .
Returns the updated TicketResponse.
{
"id": "aa0e8400-...",
"project_id": "990e8400-...",
"title": "Fix login bug",
"status": "InProgress",
"priority": "High",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Get Available Transitions
List the statuses a ticket can transition to from its current status.
GET /api/v1/tickets/{id}/transitions — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"current_status": "...",
"available": [
{
"name": "...",
"category": "..."
}
]
}
Batch Transition Tickets
Transition multiple tickets to the same target status in one call. Each ticket is validated individually against its project’s workflow. Returns per-ticket results so callers can see which succeeded and which failed. Useful for sprint completion (move all tickets to Done).
POST /api/v1/tickets/batch-transition — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to transition |
to_status | string | yes | Target status name |
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_ids":["'"$TICKET_ID"'"],"to_status":"Done"}'
{
"succeeded": [
{
"id": "...",
"status": "Done"
}
],
"failed": []
}
When a transition fails for a ticket (e.g., invalid workflow transition in strict mode,
or ticket not found), it appears in the failed array with a reason:
{
"succeeded": [],
"failed": [
{
"id": "...",
"reason": "not found: ticket ..."
}
]
}
Time Entries
Guide: Time Tracking and Finance
Create Time Entry
POST /api/v1/time-entries — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User UUID |
ticket_id | string | yes | Ticket UUID |
project_id | string | yes | Project UUID |
date | string | yes | ISO 8601 date |
duration_minutes | integer | yes | Duration in minutes |
description | string | no | Entry description |
activity_type | string | yes | Coding, CodeReview, BugFixing, Testing, Documentation, Design, Meeting, Planning |
curl -s "$BASE_URL/api/v1/time-entries" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"ticket_id\": \"$TICKET_ID\",
\"project_id\": \"$PROJECT_ID\",
\"date\": \"2026-03-28\",
\"duration_minutes\": 120,
\"description\": \"Investigated SSO bug\",
\"activity_type\": \"Coding\"
}" | jq .
{
"id": "ff0e8400-...",
"user_id": "550e8400-...",
"ticket_id": "aa0e8400-...",
"project_id": "990e8400-...",
"date": "...",
"duration_minutes": 120,
"description": "...",
"activity_type": "...",
"status": "...",
"approved_by": null,
"approved_at": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Get Time Entry
GET /api/v1/time-entries/{id} — requires auth
curl -s "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "ff0e8400-...",
"user_id": "550e8400-...",
"ticket_id": "aa0e8400-...",
"project_id": "990e8400-...",
"date": "...",
"duration_minutes": 120,
"description": "...",
"activity_type": "...",
"status": "...",
"approved_by": null,
"approved_at": null,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
List Time Entries by Ticket
GET /api/v1/tickets/{ticket_id}/time-entries — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
user_id | string | no | Filter by user |
project_id | string | no | Filter by project |
date | string | no | Filter by date |
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/time-entries" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a paginated list of TimeEntryResponse objects.
List Time Entries by User
GET /api/v1/users/{user_id}/time-entries — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
project_id | string | no | Filter by project |
date | string | no | Filter by date |
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/users/$USER_ID/time-entries" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a paginated list of TimeEntryResponse objects.
Update Time Entry
PATCH /api/v1/time-entries/{id} — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
date | string | no | New date |
duration_minutes | integer | no | New duration |
description | string | null | no | New description or null |
activity_type | string | no | New activity type |
curl -s -X PATCH "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"duration_minutes": 180}' | jq .
Returns the updated TimeEntryResponse.
Delete Time Entry
DELETE /api/v1/time-entries/{id} — requires auth
curl -s -X DELETE "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
Submit Time Entry
POST /api/v1/time-entries/{id}/submit — requires auth
Transitions a time entry from Draft to Submitted status.
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/submit" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns the updated TimeEntryResponse with "status": "Submitted".
Approve Time Entry
POST /api/v1/time-entries/{id}/approve — requires auth
Transitions a time entry from Submitted to Approved status.
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/approve" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns the updated TimeEntryResponse with "status": "Approved", approved_by, and approved_at populated.
List Submitted Time Entries
GET /api/v1/time-entries/submitted — requires auth
Returns all time entries with Submitted status, typically used by managers for approval workflows.
curl -s "$BASE_URL/api/v1/time-entries/submitted" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns an array of TimeEntryResponse objects.
Labor Rates
Guide: Time Tracking and Finance
Labor rates define the loaded cost rate per user per org, used for capitalization reporting.
All labor rate endpoints require Admin or Owner role.
Create Labor Rate
POST /api/v1/labor-rates — requires auth (Admin/Owner)
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User UUID |
org_id | string | yes | Organization UUID |
loaded_rate_cents | integer | yes | Loaded rate in cents per hour |
effective_date | string | yes | ISO 8601 date when rate takes effect |
curl -s "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 15000,
\"effective_date\": \"2026-01-01\"
}" | jq .
{
"id": "bb0e8400-...",
"user_id": "550e8400-...",
"org_id": "660e8400-...",
"loaded_rate_cents": 15000,
"effective_date": "2026-01-01",
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Get Labor Rate
GET /api/v1/labor-rates/{id} — requires auth (Admin/Owner)
curl -s "$BASE_URL/api/v1/labor-rates/$LABOR_RATE_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a UserLaborRateResponse.
Get Current Labor Rate
GET /api/v1/users/{user_id}/orgs/{org_id}/labor-rates/current — requires auth (Admin/Owner)
Returns the most recent labor rate by effective date for the given user and org.
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates/current" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a UserLaborRateResponse.
List Labor Rates
GET /api/v1/users/{user_id}/orgs/{org_id}/labor-rates — requires auth (Admin/Owner)
| Query Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 50, max 100) |
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a paginated list of UserLaborRateResponse objects.
Entity Tags
Tags are arbitrary key-value pairs attached to entities (projects, tickets, users, teams, time entries). They enable flexible metadata and filtering.
Set Tags
PUT /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags — requires auth
Sets or upserts tags on an entity. If a tag key already exists, its value is updated.
| Path Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
entity_type | string | yes | One of: project, ticket, user, team, time_entry |
entity_id | string | yes | Entity UUID |
| Body Field | Type | Required | Description |
|---|---|---|---|
tags | array | yes | Array of {"key": "...", "value": "..."} objects |
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tags": [
{"key": "department", "value": "engineering"},
{"key": "environment", "value": "production"}
]
}' | jq .
[
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "environment",
"value": "production",
"created_at": "...",
"updated_at": "..."
}
]
Get Tags
GET /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags — requires auth
Returns all tags for an entity.
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
}
]
Delete Tag
DELETE /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags/{key} — requires auth
Removes a single tag by key from an entity.
curl -s -X DELETE "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags/environment" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content.
Search by Tag
GET /api/v1/orgs/{org_id}/tags/search — requires auth
Finds all entities with a given tag key-value pair. Returns paginated results.
| Query Param | Type | Required | Description |
|---|---|---|---|
key | string | yes | Tag key to search for |
value | string | yes | Tag value to match |
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20) |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/tags/search?key=department&value=engineering" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Capitalization Reports
Guide: Time Tracking and Finance
Generate capitalization reports for software cost accounting (ASC 350-40 / IAS 38). Reports aggregate approved time entries with labor rates by project and activity type.
Get Capitalization Report
GET /api/v1/reports/capitalization — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
period | string | yes | Report period in YYYY-MM format |
group_by | string | no | Group results by team or user |
include_users | boolean | no | Include per-user breakdown in project entries |
include_budget | boolean | no | Include budget/ROI fields on each project |
team_id | string | no | Filter to a specific team UUID |
user_id | string | no | Filter to a specific user UUID |
cost_center_id | string | no | Filter to a specific cost center |
activity_type | string | no | Filter to a specific activity type (e.g. Coding) |
tag | string | no | Filter by tag. Comma-separated key:value pairs. Multiple tags combine with AND logic |
Default (flat) response:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns {"period": "2026-03", "projects": [...]} where each project includes
project_id, project_key, project_name, capitalization_type,
development_phase, cost_center_id, total_hours, total_amount_cents,
and a breakdown array of {activity_type, hours, amount_cents}.
With budget fields — add include_budget=true to include budget_cents,
budget_period, spent_cents, budget_remaining_cents, and
budget_utilization_pct on each project entry.
Grouped by team — add group_by=team to return
{"period": "...", "teams": [{team_id, team_name, total_hours, total_amount_cents, projects}]}.
Grouped by user — add group_by=user to return
{"period": "...", "users": [{user_id, display_name, total_hours, total_amount_cents, projects}]}.
Filter by tag — add tag=department:engineering to only include projects with that tag.
Export Capitalization Report (CSV)
GET /api/v1/reports/capitalization/export — requires auth
Supports all the same query parameters as the JSON endpoint above.
| Query Param | Type | Required | Description |
|---|---|---|---|
period | string | yes | Report period in YYYY-MM format |
format | string | no | Export format (default: csv) |
team_id | string | no | Filter to a specific team UUID |
user_id | string | no | Filter to a specific user UUID |
cost_center_id | string | no | Filter to a specific cost center |
activity_type | string | no | Filter to a specific activity type |
tag | string | no | Filter by tag (comma-separated key:value pairs) |
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03" \
-H "Authorization: Bearer $TOKEN" -o report.csv
Returns a CSV file with header:
Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization
Data Export & Import
Export complete project data as portable JSON or SQLite, or import data from an Alloy export to restore or migrate between instances. Relationships use human-readable keys (emails, project keys, label names) instead of UUIDs, making exports portable across Alloy instances.
Export Data
GET /api/v1/export — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
project | string | no | Filter to a single project by key (e.g. PROJ) |
format | string | no | Export format: json (default) or sqlite |
curl -s "$BASE_URL/api/v1/export?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN"
{
"version": 1,
"source": "alloy",
"exported_at": "...",
"projects": "...",
"labels": "...",
"workflows": "...",
"users": "..."
}
Filter by project key — append &project=KEY to export a single project:
GET /api/v1/export?org_id=<ORG_ID>&project=DEMO
Export as SQLite — append &format=sqlite to download a self-contained SQLite database:
curl -s "$BASE_URL/api/v1/export?org_id=$ORG_ID&format=sqlite" \
-H "Authorization: Bearer $TOKEN" -o alloy-export.db
The SQLite file contains tables: export_meta, users, labels, workflows, projects, project_members, sprints, tickets, ticket_labels, comments, time_entries. It can be opened and queried with any SQLite client.
Import Data
POST /api/v1/import — requires auth
Accepts an AlloyExport JSON body (as produced by the export endpoint) and creates all
entities in the authenticated user’s organization, preserving relationships. Users and
workflows are resolved by email/name to avoid duplicates. Returns a summary of created entities.
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | yes | Export format version (must be 1) |
source | string | yes | Source identifier (e.g. "alloy") |
exported_at | string | yes | ISO 8601 timestamp of when the export was created |
projects | array | yes | Array of project objects |
labels | array | yes | Array of label objects |
workflows | array | yes | Array of workflow objects |
users | array | yes | Array of user objects |
curl -s -X POST "$BASE_URL/api/v1/import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"version":1,"source":"alloy","exported_at":"2026-01-01T00:00:00Z","projects":[],"labels":[],"workflows":[],"users":[]}'
{
"status": "ok",
"summary": {
"users_created": 0,
"workflows_created": 0,
"labels_created": 0,
"projects_created": 0,
"sprints_created": 0,
"tickets_created": 0,
"comments_created": 0,
"time_entries_created": 0
}
}
With a file — for larger exports, use a file reference:
curl -s -X POST "$BASE_URL/api/v1/import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @alloy-export.json
Round-trip example — export from one instance and import into another:
curl -s "$SOURCE_URL/api/v1/export?org_id=$ORG_ID" \
-H "Authorization: Bearer $SOURCE_TOKEN" \
| curl -s -X POST "$TARGET_URL/api/v1/import" \
-H "Authorization: Bearer $TARGET_TOKEN" \
-H "Content-Type: application/json" \
-d @-
Validation errors — returns 422 if the export version is unsupported:
curl -s -X POST "$BASE_URL/api/v1/import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"version":99,"source":"test","exported_at":"2026-01-01T00:00:00Z","projects":[],"labels":[],"workflows":[],"users":[]}'
# → 422 {"error":"Validation error","details":["unsupported export version: 99. Expected: 1"]}
Webhooks
Webhooks deliver real-time event notifications to external URLs via HTTP POST. Events are signed with HMAC-SHA256 using the webhook secret.
Create Webhook
POST /api/v1/orgs/{org_id}/webhooks — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Destination URL (must be valid URL) |
event_types | array | yes | Events to subscribe to |
Supported event types: ticket.created, ticket.updated, ticket.status_changed, comment.created, sprint.started, sprint.completed
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/alloy",
"event_types": ["ticket.created", "ticket.updated"]
}' | jq .
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"url": "...",
"secret": "...",
"event_types": [
"...",
"..."
],
"active": true,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Note: The
secretfield is only returned on creation. Store it securely — it is used to verify webhook signatures via theX-Alloy-Signatureheader.
List Webhooks
GET /api/v1/orgs/{org_id}/webhooks — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a paginated list of WebhookResponse objects (without the secret field).
Get Webhook
GET /api/v1/webhooks/{id} — requires auth
curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "cc0e8400-...",
"org_id": "660e8400-...",
"url": "https://example.com/hooks/alloy",
"event_types": ["ticket.created", "ticket.updated"],
"active": true,
"created_at": "2026-03-28-...",
"updated_at": "2026-03-28-..."
}
Delete Webhook
DELETE /api/v1/webhooks/{id} — requires auth
curl -s -X DELETE "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
List Webhook Deliveries
GET /api/v1/webhooks/{webhook_id}/deliveries — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page size (default 20, max 100) |
curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/deliveries" \
-H "Authorization: Bearer $TOKEN" | jq .
SSO Authentication
Single sign-on via OpenID Connect (OIDC) with PKCE. Requires the org to have an SSO provider configured.
SSO Discovery
GET /api/v1/auth/sso/discover — no auth required
| Query Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
Returns the IdP authorization URL and state parameter to begin the SSO flow.
curl -s "$BASE_URL/api/v1/auth/sso/discover?org_id=$ORG_ID" | jq .
Redirect the user’s browser to authorization_url to begin login.
SSO Callback
GET /api/v1/auth/sso/callback — no auth required
| Query Param | Type | Required | Description |
|---|---|---|---|
code | string | yes | Authorization code from IdP |
state | string | yes | State parameter from discovery |
curl -s "$BASE_URL/api/v1/auth/sso/callback?code=$AUTH_CODE&state=$STATE" | jq .
SCIM Provisioning
SCIM 2.0 endpoints for automated user and group provisioning from identity providers.
All SCIM endpoints require a Bearer token and are mounted at /scim/v2/.
Configuration: SCIM provisioning requires an SSO provider to be configured for the org. The SCIM bearer token is generated during SSO setup.
Service Provider Config
GET /scim/v2/ServiceProviderConfig — requires SCIM bearer token
curl -s "$BASE_URL/scim/v2/ServiceProviderConfig" \
-H "Authorization: Bearer $SCIM_TOKEN" | jq .
Returns the SCIM service provider configuration describing supported features.
List Users (SCIM)
GET /scim/v2/Users — requires SCIM bearer token
curl -s "$BASE_URL/scim/v2/Users" \
-H "Authorization: Bearer $SCIM_TOKEN" | jq .
Create User (SCIM)
POST /scim/v2/Users — requires SCIM bearer token
| Field | Type | Required | Description |
|---|---|---|---|
userName | string | yes | Email address |
displayName | string | no | Display name |
curl -s "$BASE_URL/scim/v2/Users" \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"userName": "new@example.com", "displayName": "New User"}' | jq .
Returns the created ScimUser with 201 Created.
Get User (SCIM)
GET /scim/v2/Users/{id} — requires SCIM bearer token
curl -s "$BASE_URL/scim/v2/Users/$USER_ID" \
-H "Authorization: Bearer $SCIM_TOKEN" | jq .
Returns a ScimUser object.
Update User (SCIM)
PATCH /scim/v2/Users/{id} — requires SCIM bearer token
Uses SCIM PATCH operations. Common operation: deactivate a user.
curl -s -X PATCH "$BASE_URL/scim/v2/Users/$USER_ID" \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"Operations": [{"op": "replace", "path": "active", "value": false}]
}' | jq .
Returns the updated ScimUser.
List Groups (SCIM)
GET /scim/v2/Groups — requires SCIM bearer token
curl -s "$BASE_URL/scim/v2/Groups" \
-H "Authorization: Bearer $SCIM_TOKEN" | jq .
Create Group (SCIM)
POST /scim/v2/Groups — requires SCIM bearer token
| Field | Type | Required | Description |
|---|---|---|---|
displayName | string | yes | Group name |
members | array | no | Array of {"value": "<user_id>"} |
curl -s "$BASE_URL/scim/v2/Groups" \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"displayName": "Design", "members": [{"value": "'$USER_ID'"}]}' | jq .
Returns the created ScimGroup with 201 Created.
Get Group (SCIM)
GET /scim/v2/Groups/{id} — requires SCIM bearer token
curl -s "$BASE_URL/scim/v2/Groups/$GROUP_ID" \
-H "Authorization: Bearer $SCIM_TOKEN" | jq .
Returns a ScimGroup object.
Update Group (SCIM)
PATCH /scim/v2/Groups/{id} — requires SCIM bearer token
curl -s -X PATCH "$BASE_URL/scim/v2/Groups/$GROUP_ID" \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"Operations": [{"op": "replace", "path": "displayName", "value": "Platform Engineering"}]
}' | jq .
Returns the updated ScimGroup.
GitHub Integration
Receives GitHub webhook events to automatically transition ticket statuses based on pull request activity.
Configuration: Requires
ALLOY_GITHUB_WEBHOOK_SECRETenvironment variable. Configure a GitHub webhook pointing to this endpoint with thepull_requestevent.
Receive GitHub Webhook
POST /api/v1/integrations/github/webhook — no auth required (signature verified)
| Header | Required | Description |
|---|---|---|
X-Hub-Signature-256 | yes | HMAC-SHA256 signature of the payload |
X-GitHub-Event | yes | Event type (e.g., pull_request) |
The handler parses ticket references (e.g., PROJ-123) from PR titles and branch names, then transitions tickets:
- PR opened/reopened →
In Review - PR closed + merged →
Done - PR closed (not merged) →
In Progress
{
"status": "processed",
"ticket_refs_found": ["ALLOY-42", "ALLOY-43"],
"transitions": [
{"ticket_ref": "ALLOY-42", "old_status": "In Progress", "new_status": "In Review"},
{"ticket_ref": "ALLOY-43", "old_status": "In Progress", "new_status": "In Review"}
]
}
Slack Integration
Slack integration provides slash commands for ticket management and real-time notifications.
Configuration: Requires
ALLOY_SLACK_SIGNING_SECRET,ALLOY_SLACK_BOT_TOKEN, and optionallyALLOY_SLACK_DEFAULT_CHANNELenvironment variables.
Receive Slack Command
POST /api/v1/integrations/slack/commands — no auth required (signature verified)
| Header | Required | Description |
|---|---|---|
X-Slack-Request-Timestamp | yes | Request timestamp |
X-Slack-Signature | yes | HMAC-SHA256 signature (v0=...) |
Request body is application/x-www-form-urlencoded with Slack command fields.
Supported commands:
/ticket create PROJ Title here— creates a new ticket/ticket PROJ-42— looks up a ticket by reference
{
"response_type": "in_channel",
"blocks": [
{
"type": "section",
"text": {"type": "mrkdwn", "text": "*ALLOY-42: Fix login bug*"},
"fields": [
{"type": "mrkdwn", "text": "*Status:* In Progress"},
{"type": "mrkdwn", "text": "*Priority:* High"}
]
}
]
}
Dispatch Slack Notification
POST /api/v1/integrations/slack/notify — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
notification_type | string | yes | assignment, mention, status_change, comment_sync |
ticket_id | string | yes | Ticket UUID |
channel_id | string | no | Slack channel ID |
user_slack_id | string | no | Slack user ID for DM |
message | string | no | Custom message text |
old_status | string | no | Previous status (for status_change) |
new_status | string | no | New status (for status_change) |
actor_name | string | no | Who triggered the action |
thread_ts | string | no | Slack thread timestamp |
curl -s "$BASE_URL/api/v1/integrations/slack/notify" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"notification_type\": \"status_change\",
\"ticket_id\": \"$TICKET_ID\",
\"channel_id\": \"C0123456789\",
\"old_status\": \"In Progress\",
\"new_status\": \"Done\",
\"actor_name\": \"Jane Smith\"
}" | jq .
{
"status": "queued",
"notification_type": "status_change"
}
Receive Slack Event
POST /api/v1/integrations/slack/events — no auth required (signature verified)
Handles Slack Events API payloads, including URL verification challenges and message events for comment syncing.
# URL verification (Slack sends this during setup)
curl -s "$BASE_URL/api/v1/integrations/slack/events" \
-H "Content-Type: application/json" \
-d '{"type": "url_verification", "challenge": "abc123"}' | jq .
{
"challenge": "abc123",
"status": "ok"
}
Create Thread Mapping
POST /api/v1/integrations/slack/thread-mappings — requires auth
Links a Slack thread to an Alloy ticket for bi-directional comment syncing.
| Field | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | Ticket UUID |
channel_id | string | yes | Slack channel ID |
thread_ts | string | yes | Slack thread timestamp |
curl -s "$BASE_URL/api/v1/integrations/slack/thread-mappings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ticket_id\": \"$TICKET_ID\",
\"channel_id\": \"C0123456789\",
\"thread_ts\": \"1679012345.678900\"
}" | jq .
{
"id": "ee0e8400-...",
"ticket_id": "aa0e8400-...",
"channel_id": "C0123456789",
"thread_ts": "1679012345.678900",
"created_at": "2026-03-28-..."
}
Attachments
Request Upload URL
Get a presigned S3 URL for uploading a file.
POST /api/v1/tickets/{ticket_id}/attachments/presign — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | File name |
content_type | string | yes | MIME type |
size_bytes | integer | yes | File size in bytes |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments/presign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename": "screenshot.png", "content_type": "image/png", "size_bytes": 102400}' | jq .
{
"upload_url": "https://s3.amazonaws.com/...",
"s3_key": "..."
}
Confirm Upload
After uploading the file to the presigned URL, confirm the attachment.
POST /api/v1/tickets/{ticket_id}/attachments/confirm — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | File name |
content_type | string | yes | MIME type |
size_bytes | integer | yes | File size |
s3_key | string | yes | S3 key from presign response |
uploaded_by | string | yes | User UUID |
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments/confirm" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"filename\": \"screenshot.png\",
\"content_type\": \"image/png\",
\"size_bytes\": 102400,
\"s3_key\": \"attachments/abc123/screenshot.png\",
\"uploaded_by\": \"$USER_ID\"
}" | jq .
{
"id": "110e8400-...",
"ticket_id": "aa0e8400-...",
"filename": "screenshot.png",
"content_type": "image/png",
"size_bytes": 102400,
"s3_key": "attachments/abc123/screenshot.png",
"uploaded_by": "550e8400-...",
"created_at": "2026-03-28-...",
"download_url": null
}
List Attachments
GET /api/v1/tickets/{ticket_id}/attachments — requires auth
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments" \
-H "Authorization: Bearer $TOKEN" | jq .
Returns a paginated list of AttachmentResponse objects.
Health
Health Check
GET /health — no auth required
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "0.1.0",
"database": "sqlite",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
Version
Version Info
GET /api/v1/version — no auth required
curl -s "$BASE_URL/api/v1/version" | jq .
{
"version": "0.1.0",
"service": "alloy",
"profile": "..."
}
Deployment and Operations Guide
This guide covers deploying and operating Alloy — from single-developer SQLite to team PostgreSQL with integrations.
SQLite vs PostgreSQL: Which Backend?
| Criteria | SQLite | PostgreSQL |
|---|---|---|
| Best for | Solo developer, small team, evaluation | Teams, multi-tenant, production |
| Setup | Zero — single file, auto-created | Requires running PostgreSQL server |
| Multi-tenancy | Single-tenant only | Full multi-tenant with RLS |
| Concurrency | Limited (single writer) | High concurrency |
| Backup | Copy the .db file | pg_dump / replication |
| Migration path | Export via API, re-import into PG | N/A |
Start with SQLite if you’re evaluating Alloy or running it for personal use. Use PostgreSQL when you need multi-tenant isolation, team access, or production reliability.
Migrating from SQLite to PostgreSQL
There is no built-in migration tool. The recommended approach:
- Stand up a PostgreSQL instance and start Alloy pointed at it (migrations run automatically)
- Export your data from the SQLite instance using the API (list projects, tickets, etc.)
- Import into PostgreSQL via the API
- Switch your
ALLOY_DATABASE_URLto the PostgreSQL connection string
SQLite Deployment
The simplest deployment — a single binary with a single database file.
# Start with defaults (creates ./alloy.db automatically)
cargo run -p alloy-cli -- serve
# Or with a prebuilt binary
alloy serve
Data location: By default, alloy.db is created in the current working directory. Override with:
ALLOY_DATABASE_URL=sqlite:///var/data/alloy.db alloy serve
Backup: Just copy the .db file while the server is stopped (or use SQLite’s .backup command for online backup):
cp alloy.db alloy.db.backup
PostgreSQL Deployment
Using Docker Compose (recommended for development)
The repository includes a docker-compose.yml that runs PostgreSQL 16 and MinIO (S3-compatible storage for attachments):
docker compose up -d
This starts:
- PostgreSQL on port 5432 (user:
postgres, password:postgres, database:alloy_dev) - MinIO on port 9000 (console on 9001, user:
minioadmin, password:minioadmin) - minio-init creates the
alloy-attachmentsbucket automatically
Then start Alloy pointed at PostgreSQL:
ALLOY_DATABASE_URL=postgres://postgres:postgres@localhost:5432/alloy_dev alloy serve
Connection string format
postgres://USER:PASSWORD@HOST:PORT/DATABASE
Multi-tenancy and RLS
PostgreSQL mode enables Row-Level Security (RLS) for full multi-tenant isolation:
- Each request sets
app.tenant_idviaSET LOCALin the transaction - RLS policies ensure tenants can only see their own data
- This is transparent to the application — queries return only tenant-scoped rows
- No data leaks between organizations, even if a bug skips application-level filtering
TLS / HTTPS
Alloy supports automatic TLS certificate provisioning via Let’s Encrypt using the ACME protocol. When TLS is enabled, the server listens over HTTPS with no reverse proxy required.
Enabling automatic TLS
Pass --tls-domain to alloy serve with the public domain name:
alloy serve --tls-domain api.example.com --tls-contact admin@example.com
This will:
- Automatically request a TLS certificate from Let’s Encrypt for
api.example.com - Cache the certificate on disk (default:
./acme_cache) - Serve HTTPS on the configured port (default 3000)
CLI flags
| Flag | Description |
|---|---|
--tls-domain | Domain to provision a certificate for. Omit for plain HTTP. |
--tls-contact | Email address for Let’s Encrypt notifications (recommended). |
--tls-staging | Use the Let’s Encrypt staging environment (for testing — avoids rate limits). |
--tls-cache-dir | Directory to cache certificates. Default: ./acme_cache. |
Environment variables (TLS)
| Variable | Default | Description |
|---|---|---|
ALLOY_TLS_DOMAIN | — | Domain for automatic TLS (alternative to --tls-domain). |
ALLOY_TLS_CONTACT | — | Contact email for Let’s Encrypt (alternative to --tls-contact). |
ALLOY_TLS_STAGING | false | Use staging environment (alternative to --tls-staging). |
ALLOY_TLS_CACHE_DIR | ./acme_cache | Certificate cache directory (alternative to --tls-cache-dir). |
Example: production HTTPS
alloy serve \
--tls-domain api.example.com \
--tls-contact ops@example.com \
--port 443
Once running, verify with curl:
curl -s https://api.example.com/health
Expected response:
{"status":"ok"}
Example: staging / testing
Use --tls-staging to test certificate provisioning without hitting production rate limits:
alloy serve \
--tls-domain staging.example.com \
--tls-contact ops@example.com \
--tls-staging
Docker with TLS
Mount a volume for certificate persistence across container restarts:
docker run -d \
--name alloy \
-p 443:443 \
-v alloy-data:/data \
-v alloy-certs:/certs \
-e ALLOY_DATABASE_URL=sqlite:///data/alloy.db \
alloy \
serve --tls-domain api.example.com --tls-contact ops@example.com --tls-cache-dir /certs --port 443
Notes
- DNS must resolve first: The domain must point to your server before ACME validation can succeed.
- Port 443: Let’s Encrypt HTTP-01 challenge may require port 80 or 443 to be reachable. If running behind a firewall, ensure the challenge port is open.
- Certificate renewal: Certificates are renewed automatically before expiration. Keep the cache directory persistent.
- Without TLS flags: The server runs plain HTTP exactly as before — no TLS overhead.
Docker Deployment
Building the image
docker build -t alloy .
The multi-stage Dockerfile produces a minimal Debian-based image with just the alloy binary.
Running with SQLite
docker run -d \
--name alloy \
-p 3000:3000 \
-v alloy-data:/data \
alloy
Data is stored at /data/alloy.db inside the container. The volume mount persists it across container restarts.
Running with PostgreSQL
docker run -d \
--name alloy \
-p 3000:3000 \
-e ALLOY_DATABASE_URL=postgres://user:pass@db-host:5432/alloy \
-e ALLOY_JWT_PRIVATE_KEY_FILE=/secrets/private.pem \
-e ALLOY_JWT_PUBLIC_KEY_FILE=/secrets/public.pem \
-v /path/to/secrets:/secrets:ro \
alloy
Volume mounts
| Mount | Purpose |
|---|---|
/data | SQLite database file (default ALLOY_DATABASE_URL=sqlite:///data/alloy.db) |
/secrets | JWT key files (if using file-based keys) |
Environment Variables Reference
Core
| Variable | Default | Description |
|---|---|---|
ALLOY_DATABASE_URL | sqlite://alloy.db | Database connection string. Use sqlite://path for SQLite or postgres://... for PostgreSQL. |
ALLOY_AUTO_MIGRATE | true | Run database migrations automatically on startup. Set to false to skip. |
PORT | 3000 | TCP port the HTTP server listens on. |
ALLOY_REGISTRATION | open | Registration mode: open (anyone can register) or invite (invite-only). |
Authentication (JWT)
| Variable | Default | Description |
|---|---|---|
ALLOY_JWT_PRIVATE_KEY | (required) | Ed25519/RSA private key (PEM string) for signing JWTs. |
ALLOY_JWT_PRIVATE_KEY_FILE | — | Path to private key file (alternative to inline). |
ALLOY_JWT_PUBLIC_KEY | (required) | Corresponding public key (PEM string) for verifying JWTs. |
ALLOY_JWT_PUBLIC_KEY_FILE | — | Path to public key file (alternative to inline). |
ALLOY_JWT_ISSUER | alloy | JWT iss claim value. |
ALLOY_JWT_AUDIENCE | alloy-api | JWT aud claim value. |
ALLOY_JWT_TTL_SECONDS | 3600 | JWT token lifetime in seconds. |
S3 / Object Storage (Attachments)
| Variable | Default | Description |
|---|---|---|
ALLOY_S3_ENDPOINT | — | S3-compatible endpoint URL (e.g., http://localhost:9000 for MinIO). |
ALLOY_S3_BUCKET | alloy-attachments | Bucket name for file attachments. |
ALLOY_S3_REGION | us-east-1 | S3 region. |
ALLOY_S3_ACCESS_KEY_ID | — | S3 access key. |
ALLOY_S3_SECRET_ACCESS_KEY | — | S3 secret key. |
Security
| Variable | Default | Description |
|---|---|---|
ALLOY_HTTPS | false | Set to true to mark cookies as Secure (requires TLS termination). |
ALLOY_CORS_ORIGINS | (permissive) | Comma-separated list of allowed CORS origins. |
Rate Limiting
| Variable | Default | Description |
|---|---|---|
ALLOY_RATE_LIMIT_GLOBAL | — | Global requests per minute limit. |
ALLOY_RATE_LIMIT_AUTH | — | Authenticated endpoint requests per minute. |
ALLOY_RATE_LIMIT_LOGIN | — | Login endpoint requests per minute. |
Slack Integration
| Variable | Default | Description |
|---|---|---|
ALLOY_SLACK_SIGNING_SECRET | — | Slack app signing secret for verifying webhook requests. |
ALLOY_SLACK_BOT_TOKEN | — | Slack bot OAuth token (starts with xoxb-). |
ALLOY_SLACK_NOTIFICATION_CHANNEL | general | Default Slack channel for notifications. |
ALLOY_SLACK_DEFAULT_USER_ID | — | Fallback Slack user ID when user mapping is unavailable. |
GitHub Integration
| Variable | Default | Description |
|---|---|---|
ALLOY_GITHUB_WEBHOOK_SECRET | — | Secret for verifying GitHub webhook payloads (HMAC-SHA256). |
SCIM Provisioning
| Variable | Default | Description |
|---|---|---|
ALLOY_SCIM_BEARER_TOKEN | — | Bearer token for authenticating SCIM provisioning requests. |
ALLOY_SCIM_ORG_ID | — | Organization ID to provision SCIM users/groups into. |
MCP Server
| Variable | Default | Description |
|---|---|---|
ALLOY_API_URL | (required) | Base URL of the Alloy API (e.g., http://localhost:3000). |
ALLOY_API_TOKEN | (required) | API key for authenticating MCP requests (must start with alloy_live_ or alloy_test_). |
TUI
| Variable | Default | Description |
|---|---|---|
ALLOY_BASE_URL | http://localhost:3000 | Alloy API URL for the TUI client. |
Auto-Migration Behavior
On startup, Alloy checks ALLOY_AUTO_MIGRATE (defaults to true):
- If
true: Runs all pending migrations from the embedded migration set. Migrations are compiled into the binary — no external SQL files needed at runtime. - If
false: Skips migrations entirely. Use this in environments where you run migrations as a separate step.
Migrations are idempotent — re-running an already-applied migration is a no-op. The migration runner detects the database backend (SQLite or PostgreSQL) and applies the correct dialect automatically.
MCP Server
The MCP server (alloy-mcp) lets AI assistants like Claude interact with Alloy. For complete setup instructions, configuration examples, and troubleshooting, see the dedicated MCP Guide.
For a full reference of available tools, parameters, and response formats, see the MCP Tools Reference.
Integrations Setup
Slack
- Create a Slack App at api.slack.com/apps
- Enable Event Subscriptions and set the request URL to
https://your-alloy-host/api/v1/integrations/slack/events - Enable Slash Commands (e.g.,
/alloy) with the request URLhttps://your-alloy-host/api/v1/integrations/slack/commands - Under OAuth & Permissions, add bot scopes:
chat:write,commands - Install the app to your workspace and copy the Bot User OAuth Token
- Set environment variables:
ALLOY_SLACK_SIGNING_SECRET=your_signing_secret ALLOY_SLACK_BOT_TOKEN=xoxb-your-bot-token ALLOY_SLACK_NOTIFICATION_CHANNEL=engineering # optional
GitHub
- Create a GitHub App or use repository webhooks
- Set the webhook URL to
https://your-alloy-host/api/v1/integrations/github/webhook - Select events:
push,pull_request,issues, etc. - Generate a webhook secret and set:
ALLOY_GITHUB_WEBHOOK_SECRET=your_webhook_secret
Okta SSO (OIDC)
SSO is configured per-organization via the API (not environment variables). To set up OIDC:
- In Okta, create a new Web Application with:
- Sign-in redirect URI:
https://your-alloy-host/api/v1/auth/sso/callback - Sign-out redirect URI:
https://your-alloy-host
- Sign-in redirect URI:
- Note the Client ID, Client Secret, and Issuer URL (e.g.,
https://your-org.okta.com/oauth2/default) - Register the identity provider via the Alloy API for your organization:
curl -X POST https://your-alloy-host/api/v1/orgs/{org_id}/identity-providers \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "provider_type": "oidc", "provider_name": "okta", "issuer_url": "https://your-org.okta.com/oauth2/default", "client_id": "your-client-id", "client_secret": "your-client-secret" }' - Users can then sign in via
GET /api/v1/auth/sso/login?org={org_slug}
Troubleshooting
Port conflicts
Symptom: error binding to 0.0.0.0:3000: Address already in use
Solution: Either stop the other process using port 3000, or set a different port:
PORT=3001 alloy serve
Migration failures
Symptom: migration error or refinery errors on startup
Solutions:
- Ensure the database is accessible (correct
ALLOY_DATABASE_URL) - For PostgreSQL, verify the user has
CREATE TABLE/ALTER TABLEpermissions - If a migration was partially applied, check the
refinery_schema_historytable and fix manually - Set
ALLOY_AUTO_MIGRATE=falseto skip auto-migration and run migrations separately
Authentication errors
Symptom: 401 Unauthorized on API requests
Solutions:
- Verify JWT keys are set: both
ALLOY_JWT_PRIVATE_KEY(or_FILE) andALLOY_JWT_PUBLIC_KEY(or_FILE) are required - For API key auth, ensure the key starts with
alloy_live_oralloy_test_ - Check
ALLOY_JWT_ISSUERandALLOY_JWT_AUDIENCEmatch between token issuer and verifier - If using SSO, confirm the identity provider’s
issuer_urlis correct and reachable
Database connection issues (PostgreSQL)
Symptom: error connecting to database or connection timeouts
Solutions:
- Verify PostgreSQL is running:
pg_isready -h localhost -p 5432 - Check the connection string format:
postgres://user:password@host:port/database - Ensure the database exists:
createdb alloy_dev - For Docker Compose:
docker compose up -d postgresand wait for the health check
CORS errors
Symptom: Browser console shows CORS errors when calling the API
Solution: Set allowed origins:
ALLOY_CORS_ORIGINS=http://localhost:5173,https://your-app.com alloy serve
Slack integration not working
Symptom: Slash commands or events not reaching Alloy
Solutions:
- Verify
ALLOY_SLACK_SIGNING_SECRETmatches your Slack app’s signing secret - Ensure webhook URLs are publicly reachable (use ngrok for local development)
- Check that bot scopes include
chat:writeandcommands
MCP server connection issues
Symptom: Claude Desktop can’t connect to the MCP server
Solution: See the troubleshooting section in the MCP Guide for detailed solutions covering connection errors, authentication issues, and configuration problems.
MCP Guide
Alloy includes a built-in Model Context Protocol (MCP) server that lets AI assistants interact with your projects, tickets, sprints, and time tracking directly.
Overview
The alloy-mcp server exposes Alloy’s project management capabilities as MCP tools and resources. AI assistants like Claude can create tickets, search backlogs, log time, query sprint burndown, and more — all through the standard MCP protocol.
Transport modes:
| Mode | Use case | How it works |
|---|---|---|
| stdio | Claude Desktop, Claude Code, local dev | Binary communicates over stdin/stdout (JSON-RPC) |
| HTTP | Remote servers, shared environments | TCP listener with Bearer token auth per request |
Key features:
- 67 tools covering tickets, comments, labels, tags, projects, sprints, teams, workflows, time tracking, finance, and organization management
- 15 slash commands (MCP prompts) for guided workflows — see the MCP Tools Reference for the full list
- Resource URIs for browsing projects, tickets, sprints, and user assignments
- Authenticated via Alloy API keys — the MCP server proxies requests to the Alloy API
- Protocol version:
2024-11-05
Quick Start: From Build to Working in 6 Steps
Follow these numbered steps to go from a fresh checkout to a working MCP integration.
Step 1: Build the binary
Build the alloy-mcp binary from the workspace root:
cargo build --release -p alloy-mcp
The binary is placed at target/release/alloy-mcp. Optionally, copy it somewhere on your $PATH:
cp target/release/alloy-mcp ~/.local/bin/
Step 2: Start the Alloy API server
The MCP server needs a running Alloy API to proxy requests to. Start it in a separate terminal:
cargo run --release -p alloy-api -- serve
Or if you have the binary installed:
alloy serve
See Getting Started or Deployment for full details.
Step 3: Create an API key
Via the CLI:
alloy auth api-key create --name "MCP Server"
This prints the full key (starting with alloy_live_ or alloy_test_). Copy it immediately — the full key is only shown once.
Via curl:
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "MCP Server", "scopes": ["read", "write"]}' | jq .
Expected response:
{
"id": "770e8400-...",
"name": "MCP Server",
"key": "alloy_live_abc123...",
"key_prefix": "alloy_live_...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-28-...",
"expires_at": null
}
Save the key field — you will need it for ALLOY_API_TOKEN below.
Step 4: Test the MCP server manually
Before configuring any AI assistant, verify the binary works end-to-end on its own.
Test the stdio transport directly:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' | \
ALLOY_API_URL=http://localhost:3000 \
ALLOY_API_TOKEN=alloy_live_your_key \
alloy-mcp
You should receive a JSON-RPC response containing serverInfo with name: "alloy-mcp". If you see an error instead, fix the issue before proceeding — the AI assistant configuration will not work until this step succeeds.
Test API connectivity:
curl -s http://localhost:3000/api/v1/auth/me \
-H "Authorization: Bearer $ALLOY_API_TOKEN" | jq .
You should see your user details (user_id, org_id, email, role).
Step 5: Configure your AI assistant
Choose the assistant you want to use with Alloy.
Claude Code (project-level)
Add the MCP server to your project’s .claude/mcp.json:
{
"mcpServers": {
"alloy": {
"command": "alloy-mcp",
"env": {
"ALLOY_API_URL": "http://localhost:3000",
"ALLOY_API_TOKEN": "alloy_live_your_api_key_here"
}
}
}
}
This makes Alloy available only when working inside that project directory.
Claude Code (global)
To make Alloy available across all projects, add the same configuration to ~/.claude/mcp.json instead:
{
"mcpServers": {
"alloy": {
"command": "alloy-mcp",
"env": {
"ALLOY_API_URL": "http://localhost:3000",
"ALLOY_API_TOKEN": "alloy_live_your_api_key_here"
}
}
}
}
Tip: If
alloy-mcpis not on your$PATH, use the full path in thecommandfield (e.g.,/Users/you/repos/alloy/target/release/alloy-mcp).
Claude Desktop
Add the following to your Claude Desktop MCP configuration. On macOS this is ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"alloy": {
"command": "alloy-mcp",
"env": {
"ALLOY_API_URL": "http://localhost:3000",
"ALLOY_API_TOKEN": "alloy_live_your_api_key_here"
}
}
}
}
| Field | Description |
|---|---|
command | Path to the alloy-mcp binary. Use the full path if it is not on your $PATH. |
ALLOY_API_URL | Base URL of your running Alloy API (e.g., http://localhost:3000). |
ALLOY_API_TOKEN | The API key you created above. Must start with alloy_live_ (production) or alloy_test_ (testing). |
Step 6: Restart and verify
Restart your assistant — Claude Code and Claude Desktop do not pick up MCP config changes automatically. You must restart them after editing the configuration.
- Claude Code: Close and reopen the Claude Code session (or restart the terminal)
- Claude Desktop: Quit and relaunch the application
Verify tools appear: Open Claude and check that Alloy tools are listed (e.g., create_ticket, search_tickets, ping). Try a simple command:
“Use the ping tool to check the Alloy connection”
If the tool executes and returns a response, your MCP setup is working correctly.
HTTP Transport
For remote or shared environments, run the MCP server in HTTP mode instead of stdio:
ALLOY_API_URL=http://localhost:3000 \
ALLOY_API_TOKEN=alloy_live_your_api_key_here \
alloy-mcp --http 0.0.0.0:3001
The HTTP transport:
- Listens on the specified bind address (default port
3001in the example above) - Authenticates each request via a
Bearertoken in theAuthorizationheader - Exposes OAuth 2.1 discovery metadata at
GET /.well-known/oauth-authorization-server
Connecting an MCP client to the HTTP server:
Point your MCP client at http://<host>:3001 and provide the API token as a Bearer token. Consult your client’s documentation for HTTP/SSE transport configuration.
Troubleshooting
Connection errors
| Symptom | Cause | Fix |
|---|---|---|
| “connection refused” | Alloy API not running | Start the API server first (alloy serve or cargo run -p alloy-api -- serve) |
| “connection refused” on port 3001 | HTTP-mode MCP server not running | Start it with alloy-mcp --http 0.0.0.0:3001 |
| Timeout or hanging | API URL wrong or unreachable | Check ALLOY_API_URL — it must match the running server’s address and port |
Authentication errors
| Symptom | Cause | Fix |
|---|---|---|
| “invalid API token” | Bad or missing ALLOY_API_TOKEN | Regenerate the key; ensure it starts with alloy_live_ or alloy_test_ |
| “unauthorized” / 401 | Token expired or revoked | Create a new API key and update your config |
| “forbidden” / 403 | Token missing required scopes | Recreate the key with "scopes": ["read", "write"] |
Tools not appearing
| Symptom | Cause | Fix |
|---|---|---|
| No Alloy tools listed | Config file not loaded | Restart Claude Desktop / Claude Code after saving the config |
| No Alloy tools listed | JSON syntax error in config | Validate the JSON (e.g., jq . < .claude/mcp.json) — a trailing comma or missing quote will silently fail |
| No Alloy tools listed | Wrong config file location | Claude Code uses .claude/mcp.json (project) or ~/.claude/mcp.json (global); Claude Desktop uses ~/Library/Application Support/Claude/claude_desktop_config.json on macOS |
| Tools listed but calls fail | Binary not found | Use the full absolute path in the command field, or verify alloy-mcp is on your $PATH with which alloy-mcp |
| Tools listed but calls fail | Binary is stale / outdated | Rebuild with cargo build --release -p alloy-mcp and restart the assistant |
Debugging MCP communication
To see raw JSON-RPC messages, run the MCP server manually and inspect its output:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | \
ALLOY_API_URL=http://localhost:3000 \
ALLOY_API_TOKEN=alloy_live_your_key \
alloy-mcp 2>mcp-stderr.log
Check mcp-stderr.log for diagnostic messages. If the server starts but returns errors for tool calls, the issue is likely with the API server or API key — re-run Step 4 to isolate the problem.
MCP Tools Reference
Complete reference for all tools, resources, and slash commands (prompts) exposed by the Alloy MCP server. For setup instructions, see the MCP Guide.
Table of Contents
- Tools vs Slash Commands
- Tools Overview
- Connectivity —
ping,whoami - Tickets —
create_ticket,get_ticket,search_tickets,update_ticket,transition_ticket,batch_transition,batch_update_tickets,assign_ticket,get_my_tickets,delete_ticket - Comments —
add_comment,list_comments,update_comment,delete_comment - Label Management —
create_label,list_labels,add_ticket_label,remove_ticket_label,get_label,update_label,delete_label - Tag Management —
set_tags,get_tags,delete_tag,search_by_tag - Project Management —
create_project,list_projects,get_project,update_project,delete_project - Sprint Lifecycle —
create_sprint,list_sprints,update_sprint,start_sprint,complete_sprint,get_sprint_burndown,get_sprint,delete_sprint - Team Management —
create_team,list_teams,delete_team - Workflow Management —
create_workflow,list_workflows,update_workflow,get_workflow,delete_workflow - Projects & Sprints —
get_project_summary - Organization & Access —
list_members,create_invite,list_invites,add_project_member,remove_project_member,list_project_members,create_api_key,list_api_keys,delete_api_key - Activity —
get_ticket_activity - Time Tracking —
log_time,get_time_entry,update_time_entry,delete_time_entry,list_time_entries - Finance & Reporting —
get_time_report,get_capitalization_report,submit_time_entry,approve_time_entry,get_capitalization_export - Data Export & Import —
export_data,import_data - Resources
- Enums Reference
- Pagination
- Slash Commands (Prompts)
- Error Handling
Tools vs Slash Commands
The Alloy MCP server exposes two types of capabilities:
- Tools are functions the AI assistant calls on your behalf (e.g.,
create_ticket,search_tickets). You don’t invoke them directly — the assistant decides when to use them based on your request. - Slash commands (MCP prompts) are pre-built workflows you invoke explicitly by typing a command like
/alloy:standup. They provide the assistant with structured instructions to accomplish a multi-step task using one or more tools. Think of them as recipes that orchestrate several tool calls into a coherent workflow.
Tools Overview
The Alloy MCP server exposes 69 tools organized into fourteen categories:
| Category | Tools |
|---|---|
| Connectivity | ping, whoami |
| Tickets | create_ticket, get_ticket, search_tickets, update_ticket, transition_ticket, batch_transition, batch_update_tickets, assign_ticket, get_my_tickets, delete_ticket |
| Comments | add_comment, list_comments, update_comment, delete_comment |
| Label Management | create_label, list_labels, add_ticket_label, remove_ticket_label, get_label, update_label, delete_label |
| Tag Management | set_tags, get_tags, delete_tag, search_by_tag |
| Project Management | create_project, list_projects, get_project, update_project, delete_project |
| Sprint Lifecycle | create_sprint, list_sprints, update_sprint, start_sprint, complete_sprint, get_sprint_burndown, get_sprint, delete_sprint |
| Team Management | create_team, list_teams, delete_team |
| Workflow Management | create_workflow, list_workflows, update_workflow, get_workflow, delete_workflow |
| Projects & Sprints | get_project_summary |
| Organization & Access | list_members, create_invite, list_invites, add_project_member, remove_project_member, list_project_members, create_api_key, list_api_keys, delete_api_key |
| Activity | get_ticket_activity |
| Time Tracking | log_time, get_time_entry, update_time_entry, delete_time_entry, list_time_entries |
| Finance & Reporting | get_time_report, get_capitalization_report, submit_time_entry, approve_time_entry, get_capitalization_export |
Connectivity
ping
Check connectivity to the Alloy API.
Parameters: None
Example prompt:
Check the Alloy connection
Expected response:
Alloy MCP connected to http://localhost:3000 as alice@example.com (org 550e8400-e29b-41d4-a716-446655440000)
whoami
Get the current authenticated user’s identity.
Parameters: None
Example prompt:
Who am I logged in as?
Expected response:
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"org_id": "660e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"role": "Admin"
}
Tickets
create_ticket
Create a new ticket in a project. Returns the created ticket with its auto-assigned ticket number.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
title | string | yes | Ticket title (1–500 characters) |
description | string | no | Ticket description (markdown supported) |
status | string | no | Initial status (defaults to Backlog). Values are defined by the project’s workflow; see Status Values |
priority | string | no | Priority level (defaults to None) |
assignee_id | string | no | UUID of the user to assign |
See Status Values and Priority Values for reference values.
Example prompt:
Create a ticket in project 550e8400-… titled “Fix login timeout” with priority High
Expected response:
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"ticket_number": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Fix login timeout",
"description": null,
"status": "Backlog",
"priority": "High",
"assignee_id": null,
"reporter_id": "660e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-03-29T10:00:00Z",
"updated_at": "2026-03-29T10:00:00Z"
}
get_ticket
Get a ticket by its UUID. Returns full ticket details including status, priority, and assignee.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
Example prompt:
Get the details for ticket 770e8400-…
Expected response:
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"ticket_number": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Fix login timeout",
"description": "Users experience a 30s timeout on the login page.",
"status": "InProgress",
"priority": "High",
"assignee_id": "660e8400-e29b-41d4-a716-446655440000",
"reporter_id": "660e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-03-29T10:00:00Z",
"updated_at": "2026-03-29T11:30:00Z"
}
search_tickets
Search tickets in a project. Supports filtering by status, priority, and assignee. Returns paginated results.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
status | string | no | Filter by status (workflow-defined; see Status Values) |
priority | string | no | Filter by priority |
assignee_id | string | no | Filter by assignee UUID |
limit | integer | no | Max results per page (1–100, default 50) |
cursor | string | no | Pagination cursor from previous response |
See Pagination for details on cursor-based paging.
Example prompt:
Search for all high-priority in-progress tickets in project 550e8400-…
Expected response:
{
"data": [
{
"id": "770e8400-...",
"ticket_number": 42,
"title": "Fix login timeout",
"status": "InProgress",
"priority": "High",
"assignee_id": "660e8400-..."
}
],
"next_cursor": null,
"has_more": false
}
update_ticket
Update a ticket’s fields. Only provided fields are changed; omitted fields remain untouched.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
title | string | no | New title (1–500 characters) |
description | string | no | New description (markdown supported, or null to clear) |
status | string | no | New status (workflow-defined; see Status Values) |
priority | string | no | New priority |
assignee_id | string | no | UUID of user to assign, or null to unassign |
Example prompt:
Update ticket 770e8400-… to set priority to Urgent and add a description
Expected response:
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"ticket_number": 42,
"title": "Fix login timeout",
"description": "Critical path blocker for v2.0 release.",
"status": "InProgress",
"priority": "Urgent",
"assignee_id": "660e8400-...",
"updated_at": "2026-03-29T12:00:00Z"
}
transition_ticket
Transition a ticket to a new status. Validates against the project’s workflow rules. On error, returns the list of available transitions from the current status.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
to_status | string | yes | Target status — must be a valid transition in the project’s workflow (e.g., InProgress, InReview, Done in the default workflow) |
Example prompt:
Move ticket 770e8400-… to InReview
Expected response (success):
{
"id": "770e8400-...",
"status": "InReview",
"updated_at": "2026-03-29T14:00:00Z"
}
Expected response (invalid transition):
Transition failed: Cannot transition from Backlog to Done. Current status: Backlog. Available transitions: [Todo, Cancelled]
batch_transition
Transition multiple tickets to the same status in one call. Each ticket is validated individually against its project workflow. Returns per-ticket results: succeeded and failed arrays. Useful for sprint completion (move all to Done).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to transition |
to_status | string | yes | Target status for all tickets |
Example prompt:
Move tickets 770e8400-… and 880e9500-… to Done
Expected response:
{
"succeeded": [
{"id": "770e8400-...", "status": "Done"},
{"id": "880e9500-...", "status": "Done"}
],
"failed": []
}
batch_update_tickets
Update multiple tickets at once with the same field values. Pass ticket_ids and any combination of fields to update: status, priority, assignee_id, sprint_id, title, description. Returns the count of updated tickets.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to update |
title | string | no | New title for all tickets (1–500 characters) |
description | string | no | New description (markdown supported, or null to clear) |
status | string | no | New status for all tickets |
priority | string | no | New priority: None, Low, Medium, High, Urgent |
assignee_id | string | no | UUID of user to assign, or null to unassign |
sprint_id | string | no | UUID of sprint to assign, or null to unassign |
Example prompt:
Set priority to High on tickets 770e8400-… and 880e9500-…
Expected response:
{
"updated": 2
}
assign_ticket
Assign a ticket to a user by UUID, or unassign by omitting assignee_id. A convenience wrapper around ticket update.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
assignee_id | string | no | UUID of user to assign to, or omit to unassign |
Example prompt:
Assign ticket 770e8400-… to user 660e8400-…
Expected response:
{
"id": "770e8400-...",
"assignee_id": "660e8400-...",
"updated_at": "2026-03-29T14:30:00Z"
}
get_my_tickets
Get all tickets assigned to the current authenticated user in a project. Returns paginated results.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
status | string | no | Filter by status (workflow-defined; see Status Values) |
limit | integer | no | Max results per page (1–100, default 50) |
cursor | string | no | Pagination cursor from previous response |
Example prompt:
Show me my tickets in project 550e8400-…
Expected response:
{
"data": [
{
"id": "770e8400-...",
"ticket_number": 42,
"title": "Fix login timeout",
"status": "InProgress",
"priority": "High"
}
],
"next_cursor": null,
"has_more": false
}
delete_ticket
Delete a ticket by its UUID. Returns 204 No Content on success. Fails with 409 Conflict if the ticket has comments — use cascade=true to force deletion. Use dry_run=true with cascade to preview what would be deleted.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket to delete |
cascade | boolean | no | Force-delete the ticket and all its comments (default false) |
dry_run | boolean | no | Preview what would be deleted without actually deleting (requires cascade=true) |
Example prompt:
Delete ticket 770e8400-…
Expected response:
deleted successfully.
Comments
add_comment
Add a comment to a ticket. The comment author is set to the authenticated user. Supports threaded replies via parent_comment_id.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
body | string | yes | Comment body (markdown supported) |
parent_comment_id | string | no | UUID of parent comment for threaded replies |
Example prompt:
Add a comment to ticket 770e8400-… saying “Reproduced on staging, investigating now”
Expected response:
{
"id": "880e8400-...",
"ticket_id": "770e8400-...",
"author_id": "660e8400-...",
"body": "Reproduced on staging, investigating now",
"parent_comment_id": null,
"created_at": "2026-03-29T15:00:00Z"
}
list_comments
List comments on a ticket. Returns paginated results ordered by creation time.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
limit | integer | no | Max results per page (1–100, default 20) |
cursor | string | no | Pagination cursor from previous response |
Example prompt:
Show comments on ticket 770e8400-…
Expected response:
{
"data": [
{
"id": "880e8400-...",
"author_id": "660e8400-...",
"body": "Reproduced on staging, investigating now",
"parent_comment_id": null,
"created_at": "2026-03-29T15:00:00Z"
}
],
"next_cursor": null,
"has_more": false
}
update_comment
Update a comment. Only the comment author or an Admin can edit a comment.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
comment_id | string | yes | UUID of the comment to update |
body | string | yes | New comment body text (markdown supported) |
Example prompt:
Update comment 880e8400-… to say “Actually this is a duplicate of #45”
Expected response:
{
"id": "880e8400-...",
"ticket_id": "770e8400-...",
"author_id": "660e8400-...",
"body": "Actually this is a duplicate of #45",
"parent_comment_id": null,
"created_at": "...",
"updated_at": "..."
}
delete_comment
Delete a comment. Only the comment author or an Admin can delete a comment. Returns 204 No Content on success.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
comment_id | string | yes | UUID of the comment to delete |
Example prompt:
Delete comment 880e8400-…
Expected response:
deleted successfully.
Label Management
create_label
Create a new label in the current organization. Labels are org-scoped and can be assigned to tickets across projects.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The label name (e.g. “bug”, “feature”, “urgent”) |
color | string | yes | Hex color code (e.g. “#FF5733”). Must be 4–7 characters |
Example prompt:
Create a “bug” label with red color #FF5733
Expected response:
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF5733",
"created_at": "...",
"updated_at": "..."
}
list_labels
List all labels in the current organization.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Maximum results to return (1–100). Defaults to 20 |
cursor | string | no | Cursor for pagination from a previous response |
Example prompt:
List all labels in the organization
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF5733",
"created_at": "...",
"updated_at": "..."
}
],
"has_more": false
}
add_ticket_label
Add a label to a ticket. The label must already exist in the organization. A ticket can have multiple labels.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket to add the label to |
label_id | string | yes | UUID of the label to add |
Example prompt:
Add the “bug” label to ticket 550e8400-e29b-41d4-a716-446655440000
Expected response:
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF5733",
"created_at": "...",
"updated_at": "..."
}
remove_ticket_label
Remove a label from a ticket. Does not delete the label itself, only removes the association.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket to remove the label from |
label_id | string | yes | UUID of the label to remove |
Example prompt:
Remove the “bug” label from ticket 550e8400-e29b-41d4-a716-446655440000
Expected response:
deleted successfully.
get_label
Get a single label by its UUID. Returns the label’s name, color, and timestamps.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | UUID of the label |
Example prompt:
Get label details for label abc123-…
Expected response:
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF5733",
"created_at": "...",
"updated_at": "..."
}
update_label
Update a label. Only provided fields are changed.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | UUID of the label to update |
name | string | no | New label name |
color | string | no | New hex color code |
Example prompt:
Rename label abc123-… to “critical” and change color to #FF0000
Expected response:
{
"id": "...",
"org_id": "...",
"name": "critical",
"color": "#FF0000",
"created_at": "...",
"updated_at": "..."
}
delete_label
Delete a label by its UUID. Returns 204 No Content on success. Fails with 409 Conflict if the label is still attached to tickets — use cascade=true to force deletion.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | UUID of the label to delete |
cascade | boolean | no | Force-delete the label even if attached to tickets (default false) |
Example prompt:
Delete label abc123-…
Expected response:
deleted successfully.
Tag Management
set_tags
Set one or more key–value tags on an entity. Supported entity types: project, ticket, user, team, time_entry. If a tag key already exists on the entity its value is updated (upsert). Returns all tags currently set on the entity.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
entity_type | string | yes | The entity type to tag. One of: project, ticket, user, team, time_entry |
entity_id | string | yes | UUID of the entity to tag |
tags | array | yes | Array of {key, value} objects. Existing keys are updated |
Example prompt:
Tag ticket 770e8400-… with environment=production and team=platform
{
"entity_type": "ticket",
"entity_id": "770e8400-...",
"tags": [
{"key": "environment", "value": "production"},
{"key": "team", "value": "platform"}
]
}
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"entity_type": "ticket",
"entity_id": "770e8400-...",
"key": "environment",
"value": "production",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"org_id": "...",
"entity_type": "ticket",
"entity_id": "770e8400-...",
"key": "team",
"value": "platform",
"created_at": "...",
"updated_at": "..."
}
]
}
get_tags
Get all tags currently set on an entity. Supported entity types: project, ticket, user, team, time_entry.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
entity_type | string | yes | The entity type. One of: project, ticket, user, team, time_entry |
entity_id | string | yes | UUID of the entity to get tags for |
Example prompt:
Get all tags for project abc12345-…
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "abc12345-...",
"key": "cost_center",
"value": "eng-001",
"created_at": "...",
"updated_at": "..."
}
]
}
delete_tag
Delete a tag by key from an entity. Supported entity types: project, ticket, user, team, time_entry. Removes only the specified tag key; other tags on the entity are unchanged.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
entity_type | string | yes | The entity type. One of: project, ticket, user, team, time_entry |
entity_id | string | yes | UUID of the entity to delete a tag from |
tag_key | string | yes | The tag key to delete |
Example prompt:
Delete the “environment” tag from user 550e8400-…
Expected response:
deleted successfully.
search_by_tag
Search for entities with a specific tag key–value pair. Returns paginated entity IDs and types that match.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
key | string | yes | The tag key to search for (e.g. “environment”) |
value | string | yes | The tag value to search for (e.g. “production”) |
limit | integer | no | Maximum results to return (1–100, default 20) |
cursor | string | no | Cursor for pagination from a previous response |
Example prompt:
Find all entities tagged environment=production
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"entity_type": "ticket",
"entity_id": "...",
"key": "environment",
"value": "production",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "environment",
"value": "production",
"created_at": "...",
"updated_at": "..."
}
],
"has_more": false,
"next_cursor": null
}
Project Management
create_project
Create a new project in the current organization. Returns the full project object with its auto-generated UUID.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
key | string | yes | Unique short key for the project (1–10 characters, e.g. ALLOY) |
name | string | yes | Project name (1–255 characters) |
description | string | no | Project description |
team_id | string | no | UUID of the team that owns this project |
budget_cents | integer | no | Budget amount in cents (e.g. 100000 = $1,000.00) |
budget_period | string | no | Budget period: Monthly, Quarterly, Yearly, or Fixed |
Example prompt:
Create a project with key “ROCKET” named “Rocket Launch” with description “Space exploration project”
Expected response:
{
"id": "990e8400-e29b-41d4-a716-446655440000",
"org_id": "660e8400-...",
"team_id": null,
"workflow_id": null,
"key": "ROCKET",
"name": "Rocket Launch",
"description": "Space exploration project",
"ticket_counter": 0,
"capitalization_type": null,
"development_phase": null,
"cost_center_id": null,
"amortization_months": null,
"budget_cents": null,
"budget_period": null,
"created_at": "2026-03-30T10:00:00Z",
"updated_at": "2026-03-30T10:00:00Z"
}
list_projects
List all projects in the current organization. Returns paginated results.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Max results per page (1–100, default 20) |
cursor | string | no | Pagination cursor from previous response |
See Pagination for details on cursor-based paging.
Example prompt:
List all projects
Expected response:
{
"items": [
{
"id": "990e8400-...",
"org_id": "660e8400-...",
"key": "ROCKET",
"name": "Rocket Launch",
"description": "Space exploration project",
"ticket_counter": 0,
"created_at": "2026-03-30T10:00:00Z",
"updated_at": "2026-03-30T10:00:00Z"
}
],
"next_cursor": null,
"has_more": false
}
get_project
Get a single project by its UUID. Returns full project details including key, name, description, team, and budget info.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
Example prompt:
Get project 990e8400-…
Expected response:
{
"id": "990e8400-e29b-41d4-a716-446655440000",
"org_id": "660e8400-...",
"team_id": null,
"workflow_id": null,
"key": "ROCKET",
"name": "Rocket Launch",
"description": "Space exploration project",
"ticket_counter": 5,
"capitalization_type": null,
"development_phase": null,
"cost_center_id": null,
"amortization_months": null,
"budget_cents": null,
"budget_period": null,
"created_at": "2026-03-30T10:00:00Z",
"updated_at": "2026-03-30T11:00:00Z"
}
update_project
Update a project’s fields. Only provided fields are changed; omitted fields remain untouched. Pass null to clear optional fields.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project to update |
name | string | no | New project name (1–255 characters) |
description | string | null | no | New description, or null to clear |
team_id | string | null | no | UUID of team that owns this project, or null to clear |
budget_cents | integer | null | no | Budget in cents (e.g. 100000 = $1,000.00), or null to clear |
budget_period | string | null | no | Monthly, Quarterly, Yearly, Fixed, or null to clear |
capitalization_type | string | null | no | CapEx or OpEx, or null to clear |
development_phase | string | null | no | Planning, Development, Maintenance, Sunset, or null to clear |
cost_center_id | string | null | no | Cost center ID string, or null to clear |
amortization_months | integer | null | no | Amortization period in months, or null to clear |
Example prompt:
Rename project 990e8400-… to “Rocket Launch v2” and set budget to $5,000 monthly
Expected response:
{
"id": "990e8400-e29b-41d4-a716-446655440000",
"org_id": "660e8400-...",
"team_id": null,
"workflow_id": null,
"key": "ROCKET",
"name": "Rocket Launch v2",
"description": "Space exploration project",
"ticket_counter": 5,
"capitalization_type": null,
"development_phase": null,
"cost_center_id": null,
"amortization_months": null,
"budget_cents": 500000,
"budget_period": "Monthly",
"created_at": "2026-03-30T10:00:00Z",
"updated_at": "2026-03-30T12:00:00Z"
}
delete_project
Delete a project by ID. By default, fails with dependency info if the project has tickets or sprints. Use cascade mode to delete all dependents, or dry-run to preview.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project to delete |
cascade | boolean | no | If true, delete all dependent tickets, sprints, and comments (default false) |
dry_run | boolean | no | If true (with cascade), preview what would be deleted without actually deleting (default false) |
Example prompt:
Delete project 990e8400-… and all its tickets
Expected response (success with cascade):
Deleted project 990e8400-... and all dependents
Expected response (blocked, no cascade):
{
"error": {
"code": "conflict",
"message": "Cannot delete project: has dependents",
"details": {
"project_id": "990e8400-...",
"dependents": {
"tickets": 5,
"sprints": 2,
"comments": 12
}
}
}
}
Expected response (cascade dry-run):
{
"project_id": "990e8400-...",
"dependents": {
"tickets": 5,
"sprints": 2,
"comments": 12
}
}
Sprint Lifecycle
create_sprint
Create a new sprint in a project. Requires a name, start date, and end date.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
name | string | yes | Sprint name (e.g. “Sprint 1”) |
goal | string | no | Sprint goal describing what the team aims to accomplish |
start_date | string | yes | Start date in YYYY-MM-DD format |
end_date | string | yes | End date in YYYY-MM-DD format |
Example prompt:
Create a two-week sprint starting March 30 in project 550e8400-…
Expected response:
{
"id": "...",
"project_id": "550e8400-...",
"name": "Sprint 1",
"goal": "Complete onboarding flow",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
list_sprints
List sprints for a project. Returns paginated results with sprint details.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
limit | integer | no | Maximum results to return (1–100, default 20) |
cursor | string | no | Cursor from a previous response for pagination |
Example prompt:
List sprints for project 550e8400-…
Expected response:
{
"data": [
{
"id": "...",
"project_id": "550e8400-...",
"name": "Sprint 1",
"goal": "Complete onboarding flow",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null
}
update_sprint
Update a sprint’s details. All fields are optional — only provided fields are changed.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint to update |
name | string | no | New sprint name |
goal | string | no | New sprint goal (or null to clear) |
start_date | string | no | New start date in YYYY-MM-DD format |
end_date | string | no | New end date in YYYY-MM-DD format |
Example prompt:
Rename sprint abc123 to “Sprint 2 - API Polish” and update the goal
Expected response:
{
"id": "...",
"project_id": "...",
"name": "Sprint 2 - API Polish",
"goal": "Polish all API endpoints",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
start_sprint
Start a sprint by transitioning its status from Planned to Active. Fails if the sprint is not in Planned status.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint to start |
Example prompt:
Start sprint abc123
Expected response:
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "...",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
complete_sprint
Complete a sprint by transitioning its status from Active to Completed. Fails if the sprint is not in Active status.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint to complete |
Example prompt:
Complete sprint abc123
Expected response:
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "...",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Completed",
"created_at": "...",
"updated_at": "..."
}
get_sprint_burndown
Get burndown chart data for a sprint. Returns daily data points showing total, completed, and remaining story points or ticket counts over the sprint duration.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint |
Example prompt:
Show me the burndown for sprint 990e8400-…
Expected response:
{
"sprint_id": "990e8400-...",
"data_points": [
{ "date": "2026-03-24", "total": 20, "completed": 0, "remaining": 20 },
{ "date": "2026-03-25", "total": 20, "completed": 3, "remaining": 17 },
{ "date": "2026-03-26", "total": 20, "completed": 7, "remaining": 13 }
]
}
get_sprint
Get a single sprint by its UUID. Returns sprint details including name, goal, start/end dates, and status.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint |
Example prompt:
Get details for sprint 990e8400-…
Expected response:
{
"id": "990e8400-...",
"project_id": "550e8400-...",
"name": "Sprint 1",
"goal": "Complete onboarding flow",
"start_date": "2026-03-30",
"end_date": "2026-04-13",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
delete_sprint
Delete a sprint by its UUID. Returns 204 No Content on success. Fails with 409 Conflict if the sprint has tickets — use cascade=true to force deletion. Use dry_run=true with cascade to preview what would be deleted.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sprint_id | string | yes | UUID of the sprint to delete |
cascade | boolean | no | Force-delete the sprint and unlink its tickets (default false) |
dry_run | boolean | no | Preview what would be deleted without actually deleting (requires cascade=true) |
Example prompt:
Delete sprint 990e8400-…
Expected response:
deleted successfully.
Team Management
create_team
Create a new team in the current organization. Teams group users and can own projects.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | Team name |
description | string | no | Team description |
Example prompt:
Create a team called “Backend” with description “Backend engineering team”
Expected response:
{
"id": "...",
"org_id": "...",
"name": "Backend",
"description": "Backend engineering team",
"created_at": "...",
"updated_at": "..."
}
list_teams
List all teams in the current organization. Returns team name, description, and timestamps.
Parameters:
None.
Example prompt:
List all teams
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"name": "Backend",
"description": "Backend engineering team",
"created_at": "...",
"updated_at": "..."
}
],
"has_more": false,
"next_cursor": null
}
delete_team
Delete a team by its UUID. Returns 204 No Content on success. Fails with 409 Conflict if the team has members or projects — use cascade=true to force deletion. Use dry_run=true with cascade to preview what would be deleted.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
team_id | string | yes | UUID of the team to delete |
cascade | boolean | no | Force-delete the team and remove all associations (default false) |
dry_run | boolean | no | Preview what would be deleted without actually deleting (requires cascade=true) |
Example prompt:
Delete team abc123-…
Expected response:
deleted successfully.
Workflow Management
create_workflow
Create a new workflow in the current organization. A workflow defines custom statuses, allowed transitions between statuses, and an enforcement mode.
Enforcement Modes:
| Mode | Description |
|---|---|
none | Transitions are not enforced — tickets can move to any status freely (default) |
warn | Invalid transitions are allowed but produce a warning in the response |
strict | Only transitions defined in the workflow are allowed — invalid transitions are rejected with an error |
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The workflow name (e.g. “Default”, “Bug Triage”) |
statuses | array | Yes | List of status objects, each with name (string) and category (“todo”, “in_progress”, or “done”) |
transitions | array | Yes | List of transition objects, each with from (string) and to (string) status names |
enforcement | string | No | Enforcement mode: “none” (default), “warn”, or “strict” |
Example prompt:
Create a workflow called “Bug Triage” with statuses Open (todo), Investigating (in_progress), and Fixed (done), with transitions from Open to Investigating and from Investigating to Fixed, enforcement set to warn
Expected response:
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Investigating", "category": "in_progress"},
{"name": "Fixed", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Investigating"},
{"from": "Investigating", "to": "Fixed"}
],
"enforcement": "warn",
"created_at": "...",
"updated_at": "..."
}
list_workflows
List all workflows in the current organization. Returns paginated results.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Maximum number of results (1–100, default 20) |
cursor | string | No | Cursor from a previous next_cursor for pagination |
Example prompt:
List all workflows in the org
Expected response:
{
"data": [
{
"id": "...",
"org_id": "...",
"name": "Default",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Done"}
],
"enforcement": "none",
"created_at": "...",
"updated_at": "..."
}
],
"has_more": false,
"next_cursor": null
}
update_workflow
Update an existing workflow’s fields. Only provided fields are changed.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
workflow_id | string | Yes | The UUID of the workflow to update |
name | string | No | New name for the workflow |
statuses | array | No | New list of statuses (replaces all existing) |
transitions | array | No | New list of transitions (replaces all existing) |
enforcement | string | No | New enforcement mode: “none”, “warn”, or “strict” |
Example prompt:
Update workflow wf-uuid-1 to use strict enforcement
Expected response:
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Investigating", "category": "in_progress"},
{"name": "Fixed", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Investigating"},
{"from": "Investigating", "to": "Fixed"}
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
get_workflow
Get a workflow by its UUID. Returns the workflow with name, statuses, transitions, and enforcement mode.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
workflow_id | string | yes | UUID of the workflow |
Example prompt:
Get workflow details for wf-uuid-1
Expected response:
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Investigating", "category": "in_progress"},
{"name": "Fixed", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Investigating"},
{"from": "Investigating", "to": "Fixed"}
],
"enforcement": "warn",
"created_at": "...",
"updated_at": "..."
}
delete_workflow
Delete a workflow by its UUID. Returns 204 No Content on success. Requires admin role.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
workflow_id | string | yes | UUID of the workflow to delete |
Example prompt:
Delete workflow wf-uuid-1
Expected response:
deleted successfully.
Projects & Sprints
get_project_summary
Get a comprehensive project summary. Aggregates multiple API calls into a single response: project details, ticket counts by status, and active/planned sprints.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
Example prompt:
Give me a summary of project 550e8400-…
Expected response:
{
"project": {
"id": "550e8400-...",
"name": "Alloy Core",
"key": "ALLOY",
"description": "Headless project management platform"
},
"ticket_summary": {
"total": 24,
"by_status": {
"Backlog": 8,
"Todo": 5,
"InProgress": 4,
"InReview": 3,
"Done": 4
},
"has_more": false
},
"sprints": [
{
"id": "990e8400-...",
"name": "Sprint 3",
"status": "Active",
"start_date": "2026-03-24",
"end_date": "2026-04-07"
}
]
}
Organization & Access
This section covers tools for managing organization members, invitations, project membership, and API keys. These tools require appropriate permissions — see Role Values and API Key Scopes for details.
list_members
List all members of the current organization. Returns each member’s user ID, display name, email, role, and join date.
Parameters: None
Example prompt:
Who are the members of this organization?
Expected response:
{
"items": [
{
"user_id": "...",
"display_name": "Alice Smith",
"email": "alice@example.com",
"role": "Owner",
"joined_at": "2026-01-01T00:00:00Z"
},
{
"user_id": "...",
"display_name": "Bob Jones",
"email": "bob@example.com",
"role": "Member",
"joined_at": "2026-02-01T00:00:00Z"
}
]
}
create_invite
Create an invite for someone to join the current organization. Returns an invite code and link. Optionally specify the invitee’s email and their role. See Role Values for valid roles.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
email | string | no | Email address of the person to invite (omit for a generic invite link) |
role | string | no | Role to assign: Owner, Admin, Member, Reporter, or Viewer. Defaults to Member |
Example prompt:
Invite alice@example.com as an Admin
Expected response:
{
"id": "...",
"invite_code": "ABC123xyz789",
"invite_link": "/register?invite=ABC123xyz789",
"email": "alice@example.com",
"role": "Admin",
"expires_at": "2026-04-06T10:00:00Z"
}
list_invites
List all pending invites for the current organization. Returns each invite’s ID, email, role, invite code, expiry, and status.
Parameters: None
Example prompt:
Show me all pending invites
Expected response:
[
{
"id": "...",
"org_id": "...",
"email": "pending@example.com",
"invite_code": "CODE1",
"role": "Member",
"created_by": "...",
"expires_at": "2026-04-06T10:00:00Z",
"accepted_at": null,
"revoked_at": null,
"created_at": "2026-03-30T10:00:00Z"
}
]
add_project_member
Add a user to a project as a member. Requires the user’s UUID and the project’s UUID.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
user_id | string | yes | UUID of the user to add |
Example prompt:
Add user abc123 to project def456
Expected response:
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
remove_project_member
Remove a user from a project.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
user_id | string | yes | UUID of the user to remove |
Example prompt:
Remove user abc123 from project def456
Expected response:
The tool returns a confirmation: "deleted successfully."
list_project_members
List all members of a project.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
project_id | string | yes | UUID of the project |
Example prompt:
List members of project def456
Expected response:
[
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
]
create_api_key
Create a new API key for the current user. Returns the full key value (only shown once). Optionally specify scopes and project restrictions. See API Key Scopes for valid scope values.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human-readable name for the key (1–255 characters) |
scopes | array of strings | no | Permissions: read, write, admin. Defaults to ["read", "write"] |
project_ids | array of strings | no | UUIDs of projects to restrict the key to |
Example prompt:
Create an API key called “CI Pipeline” with read and write scopes
Expected response:
{
"id": "...",
"name": "CI Pipeline",
"key": "alloy_live_abc123...",
"key_prefix": "alloy_live_abc",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-30T12:00:00Z",
"expires_at": null
}
list_api_keys
List all API keys belonging to the current user. Returns key metadata (prefix only, not the full key).
Parameters: None
Example prompt:
Show my API keys
Expected response:
[
{
"id": "...",
"name": "CI Pipeline",
"key_prefix": "alloy_live_abc",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-30T12:00:00Z",
"last_used_at": "2026-03-30T14:00:00Z",
"expires_at": null
}
]
delete_api_key
Delete an API key by its UUID. Only the key’s owner can delete it.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | UUID of the API key to delete |
Example prompt:
Delete API key abc12345-…
Expected response:
The tool returns a confirmation: "deleted successfully."
Activity
get_ticket_activity
Get the activity feed for a ticket. Returns a chronological stream of comments and changes (status transitions, field updates, etc.) with actor, action, and timestamp. Supports cursor-based pagination.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
limit | integer | no | Max results per page (1–100, default 20) |
cursor | string | no | Pagination cursor from previous response |
Example prompt:
Show activity on ticket abc-123-def
Expected response:
{
"items": [
{
"type": "change",
"actor": "...",
"timestamp": "2026-03-30T12:00:00Z",
"payload": {
"audit_log_id": "...",
"action": "update",
"entity_type": "ticket",
"changes": [
{ "field": "status", "old": "Backlog", "new": "InProgress" }
]
}
},
{
"type": "comment",
"actor": "...",
"timestamp": "2026-03-30T11:00:00Z",
"payload": {
"comment_id": "...",
"body": "Started working on this",
"parent_comment_id": null
}
}
],
"next_cursor": null,
"has_more": false
}
Time Tracking
log_time
Log time spent working on a ticket. Creates a time entry with the specified duration, date, and activity type.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
project_id | string | yes | UUID of the project |
date | string | yes | Date the work was done (YYYY-MM-DD) |
duration_minutes | integer | yes | Duration of work in minutes |
activity_type | string | yes | One of the 13 activity types (see below) |
description | string | no | Description of the work done |
Activity types (all 13):
| Activity Type | Description |
|---|---|
Coding | Writing or modifying application code |
Testing | Writing or running tests |
CodeReview | Reviewing pull requests and code |
Design | UI/UX design work |
Architecture | System design and architecture decisions |
PM | Project management tasks |
Requirements | Gathering and documenting requirements |
Training | Learning, onboarding, or training activities |
Maintenance | Infrastructure and dependency maintenance |
BugFixing | Investigating and fixing bugs |
Documentation | Writing or updating documentation |
Deployment | Deploying, releasing, or CI/CD work |
Meetings | Meetings, standups, and ceremonies |
Example prompt:
Log 90 minutes of Coding on ticket 770e8400-… in project 550e8400-… for today
Expected response:
{
"id": "aa0e8400-...",
"user_id": "660e8400-...",
"ticket_id": "770e8400-...",
"project_id": "550e8400-...",
"date": "2026-03-29",
"duration_minutes": 90,
"activity_type": "Coding",
"description": null,
"status": "Draft",
"created_at": "2026-03-29T16:00:00Z"
}
get_time_entry
Get a time entry by its UUID. Returns the time entry with date, duration, activity type, status, and associated ticket/project info.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
time_entry_id | string | yes | UUID of the time entry |
Example prompt:
Get time entry aa0e8400-…
Expected response:
{
"id": "aa0e8400-...",
"user_id": "660e8400-...",
"ticket_id": "770e8400-...",
"project_id": "550e8400-...",
"date": "2026-03-29",
"duration_minutes": 90,
"activity_type": "Coding",
"description": null,
"status": "Draft",
"created_at": "..."
}
update_time_entry
Update a draft time entry. Only draft entries can be updated. Only provided fields are changed.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
time_entry_id | string | yes | UUID of the time entry to update |
date | string | no | New date (YYYY-MM-DD) |
duration_minutes | integer | no | New duration in minutes |
description | string | no | New description |
activity_type | string | no | New activity type |
Example prompt:
Update time entry aa0e8400-… to 120 minutes and change activity to CodeReview
Expected response:
{
"id": "aa0e8400-...",
"user_id": "660e8400-...",
"ticket_id": "770e8400-...",
"project_id": "550e8400-...",
"date": "2026-03-29",
"duration_minutes": 120,
"activity_type": "CodeReview",
"description": null,
"status": "Draft",
"created_at": "..."
}
delete_time_entry
Delete a draft time entry by its UUID. Only draft entries can be deleted. Returns 204 No Content on success.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
time_entry_id | string | yes | UUID of the time entry to delete |
Example prompt:
Delete time entry aa0e8400-…
Expected response:
deleted successfully.
list_time_entries
List time entries for a specific ticket. Returns paginated results with time entry details.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | UUID of the ticket |
limit | integer | no | Max results per page (1–100, default 20) |
cursor | string | no | Pagination cursor from previous response |
Example prompt:
List time entries for ticket 770e8400-…
Expected response:
{
"data": [
{
"id": "aa0e8400-...",
"user_id": "660e8400-...",
"date": "2026-03-29",
"duration_minutes": 90,
"activity_type": "Coding",
"description": null,
"status": "Draft"
}
],
"next_cursor": null,
"has_more": false
}
Finance & Reporting
get_time_report
Get time entries for a user. Returns paginated time entries showing logged hours, activity types, and approval status.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
user_id | string | no | UUID of the user (defaults to authenticated user) |
limit | integer | no | Max results per page (1–100, default 50) |
cursor | string | no | Pagination cursor from previous response |
Example prompt:
Show my recent time entries
Expected response:
{
"data": [
{
"id": "aa0e8400-...",
"ticket_id": "770e8400-...",
"date": "2026-03-29",
"duration_minutes": 90,
"activity_type": "Coding",
"description": null,
"status": "Draft"
}
],
"next_cursor": null,
"has_more": false
}
get_capitalization_report
Get capitalization report for a given period. Returns per-project capitalization breakdown with activity types, hours, and amounts. Supports grouping by team or user, filtering by team/user/cost-center/activity-type/tag, and including budget utilization data.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
period | string | yes | Period in YYYY-MM format (e.g. “2026-03”) |
group_by | string | no | Group results by “team” or “user” |
team_id | string | no | Filter by team UUID |
user_id | string | no | Filter by user UUID |
cost_center_id | string | no | Filter by cost center ID |
activity_type | string | no | Filter by activity type (e.g. “Coding”, “Design”) |
tag | string | no | Comma-separated tag filters in key:value format |
include_users | boolean | no | Include per-user breakdown within each project |
include_budget | boolean | no | Include budget utilization data |
Example prompt:
Show me the capitalization report for March 2026
Expected response:
{
"period": "2026-03",
"projects": [
{
"project_id": "...",
"project_key": "ALY",
"project_name": "Alloy",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "CC-100",
"total_hours": 160.0,
"total_amount_cents": 2400000,
"breakdown": [
{ "activity_type": "Coding", "hours": 120.0, "amount_cents": 1800000 },
{ "activity_type": "Design", "hours": 40.0, "amount_cents": 600000 }
]
}
]
}
Grouping with group_by:
Use group_by=team to aggregate capitalization data by team, or group_by=user for per-person breakdown:
Show capitalization report for March 2026 grouped by team
Parameters: period=2026-03, group_by=team
Show capitalization report for March 2026 grouped by user
Parameters: period=2026-03, group_by=user
Including budget with include_budget:
Set include_budget=true to include budget utilization and ROI data for each project. The response adds budget_cents, budget_used_cents, budget_remaining_cents, and roi_percent fields:
Show capitalization with budget data for March 2026
Parameters: period=2026-03, include_budget=true
Example response with budget fields:
{
"period": "2026-03",
"projects": [
{
"project_id": "...",
"project_key": "ALY",
"project_name": "Alloy",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "CC-100",
"total_hours": 160.0,
"total_amount_cents": 2400000,
"breakdown": [
{ "activity_type": "Coding", "hours": 120.0, "amount_cents": 1800000 },
{ "activity_type": "Design", "hours": 40.0, "amount_cents": 600000 }
],
"budget_cents": 5000000,
"budget_used_cents": 2400000,
"budget_remaining_cents": 2600000,
"roi_percent": 48.0
}
]
}
submit_time_entry
Submit a draft time entry for approval. Changes the entry status from Draft to Submitted. Only the author of the time entry can submit it.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
time_entry_id | string | yes | UUID of the time entry to submit |
Example prompt:
Submit my time entry te-1 for approval
Expected response:
{
"id": "aa0e8400-...",
"ticket_id": "770e8400-...",
"date": "2026-03-29",
"duration_minutes": 90,
"activity_type": "Coding",
"status": "Submitted",
"approved_by": null,
"approved_at": null
}
approve_time_entry
Approve a submitted time entry. Changes the entry status from Submitted to Approved. Records who approved it and when. Requires admin role.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
time_entry_id | string | yes | UUID of the time entry to approve |
Example prompt:
Approve time entry te-2
Expected response:
{
"id": "bb0e8400-...",
"ticket_id": "770e8400-...",
"date": "2026-03-29",
"duration_minutes": 120,
"activity_type": "Testing",
"status": "Approved",
"approved_by": "550e8400-...",
"approved_at": "2026-03-30T09:00:00Z"
}
get_capitalization_export
Export capitalization report as CSV for a given period. Returns CSV text with columns: Period, Project, ProjectKey, Employee, Department, CostCenter, Hours, ActivityType, Phase, CapExOpEx, LoadedRate, Amount, Team, Tags, BudgetCents, SpentCents, Utilization. Supports filtering by team, user, cost center, activity type, and tags.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
period | string | yes | Period in YYYY-MM format (e.g. “2026-03”) |
team_id | string | no | Filter by team UUID |
cost_center_id | string | no | Filter by cost center ID |
activity_type | string | no | Filter by activity type (e.g. “Coding”) |
tag | string | no | Comma-separated tag filters in key:value format |
Example prompt:
Export the capitalization report for March 2026 as CSV
Expected response:
Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization
2026-03,Alloy,ALY,Jane Doe,Engineering,CC-100,120.0,Coding,AppDevelopment,Capex,15000,1800000,Backend,env:prod,5000000,2400000,48.0
Data Export & Import
export_data
Export complete project data as portable JSON or SQLite. All relationships use human-readable keys (emails, project keys, label names) instead of UUIDs.
| Parameter | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
project | string | no | Project key to filter (e.g. PROJ) |
format | string | no | Export format: json (default) or sqlite |
Returns: Complete AlloyExport JSON (default), or a downloadable SQLite database file when format=sqlite.
Export everything
{
"org_id": "...",
"project": null
}
Response:
{
"version": 1,
"source": "alloy",
"exported_at": "...",
"projects": ["..."],
"labels": ["..."],
"workflows": ["..."],
"users": ["..."]
}
Export a single project
{
"project": "PROJ"
}
import_data
Import data into the current organization from an Alloy JSON export (as produced by export_data). Creates users, workflows, labels, projects, sprints, tickets, comments, and time entries, preserving all relationships. Users and workflows are resolved by email/name to avoid duplicates.
| Parameter | Type | Required | Description |
|---|---|---|---|
data | object | yes | The full Alloy export JSON object. Must contain version, source, exported_at, projects, labels, workflows, and users fields. |
Returns: Summary of what was created.
Import a full export
{
"data": {
"version": 1,
"source": "alloy",
"exported_at": "2026-03-31T12:00:00Z",
"projects": [],
"labels": [],
"workflows": [],
"users": []
}
}
Response:
{
"status": "ok",
"summary": {
"users_created": 0,
"workflows_created": 0,
"labels_created": 0,
"projects_created": 0,
"sprints_created": 0,
"tickets_created": 0,
"comments_created": 0,
"time_entries_created": 0
}
}
Resources
Resources provide read-only access to Alloy data via URI patterns. MCP clients can browse resources without invoking tools.
Static Resources
| URI | Name | Description |
|---|---|---|
alloy://projects | projects | List all projects in the organization |
alloy://user/{current_user_id}/assigned | my-assigned-tickets | Tickets assigned to the current authenticated user |
Resource Templates
| URI Pattern | Name | Description |
|---|---|---|
alloy://project/{key} | project | Get a project by its key (e.g., PROJ) |
alloy://ticket/{id} | ticket | Get a ticket by its UUID |
alloy://sprint/{id}/board | sprint-board | Board view for a sprint — tickets grouped by status |
alloy://user/{id}/assigned | user-assigned-tickets | Tickets assigned to a specific user |
All resources return application/json.
Enums Reference
Status Values
Status values are defined by each project’s workflow and may vary between projects. The values below are the defaults provided by the built-in workflow. Your project may have different or additional statuses depending on its workflow configuration.
Default workflow statuses (examples):
| Value | Description |
|---|---|
Backlog | Not yet scheduled for work |
Todo | Scheduled but not started |
InProgress | Actively being worked on |
InReview | Work complete, awaiting review |
Done | Completed and accepted |
Cancelled | Cancelled, will not be done |
Tip: Use
transition_ticketto discover available transitions from a ticket’s current status. If you attempt an invalid transition, the error response lists the valid target statuses for that ticket’s workflow.
Priority Values
Ticket urgency levels used in create, update, and search operations.
| Value | Description |
|---|---|
None | No priority set (default) |
Low | Low urgency |
Medium | Normal urgency |
High | Important, address soon |
Urgent | Critical, address immediately |
Role Values
Organization roles control what a user can do. Roles are hierarchical — each role includes all permissions of the roles below it.
| Value | Privilege Level | Description |
|---|---|---|
Owner | 50 | Full control: billing, org deletion, and all admin capabilities |
Admin | 40 | Manage members, invites, projects, and settings |
Member | 30 | Create and manage tickets, sprints, and project content |
Reporter | 20 | Create tickets and comments, but cannot manage projects or members |
Viewer | 10 | Read-only access to all organization data |
Roles are used in create_invite (to set the invitee’s role) and returned by list_members and whoami.
API Key Scopes
API key scopes control what operations a key can perform. Multiple scopes can be combined. Keys default to ["read", "write"] if no scopes are specified.
| Scope | Description |
|---|---|
read | Read-only access: list and get operations (tickets, projects, members, etc.) |
write | Create and update operations: create tickets, log time, add comments, etc. |
admin | Administrative operations: manage members, invites, org settings |
Scopes are specified when calling create_api_key and returned by list_api_keys. Keys can optionally be restricted to specific projects via project_ids.
Key prefix convention: Production keys are prefixed alloy_live_, test keys are prefixed alloy_test_.
Sprint Status Values
| Value | Description |
|---|---|
Planned | Sprint has not started |
Active | Sprint is currently in progress |
Completed | Sprint has ended |
Time Entry Status Values
Approval lifecycle for time entries.
| Value | Description |
|---|---|
Draft | Entry created, not yet submitted |
Submitted | Submitted for approval |
Approved | Approved by a manager |
Rejected | Rejected, needs correction |
Pagination
All list endpoints use cursor-based pagination. The response includes:
| Field | Type | Description |
|---|---|---|
data | array | Array of result items |
next_cursor | string or null | Cursor to pass for the next page, or null if no more results |
has_more | boolean | true if additional results exist beyond this page |
Usage: Pass the next_cursor value as the cursor parameter in your next request to fetch the next page. Continue until has_more is false or next_cursor is null.
Example — paginating through tickets:
- First request:
search_ticketswithproject_idandlimit: 10 - Response includes
"next_cursor": "eyJ...","has_more": true - Next request: same parameters plus
cursor: "eyJ..." - Continue until
has_moreisfalse
Slash Commands (Prompts)
The Alloy MCP server provides 15 slash commands — pre-built workflows that guide the assistant through multi-step tasks. Invoke them by typing the command in your AI client (e.g., Claude Code).
| Command | Description |
|---|---|
/alloy:assign | Assign a ticket to a team member |
/alloy:comment | Add a comment to a ticket |
/alloy:create-ticket | Interactively gather fields and create a new ticket |
/alloy:invite | Invite a user to the organization |
/alloy:log-work | Interactively log time spent on a ticket |
/alloy:move | Move a ticket to a new workflow status |
/alloy:my-work | Fetch and summarize your assigned tickets by status |
/alloy:new-project | Create a new Alloy project |
/alloy:ping | Check server connectivity and authentication status |
/alloy:plan-sprint | Guide sprint planning with backlog review and capacity |
/alloy:project-summary | High-level overview of all projects and their health |
/alloy:report | Generate a project status report for a given period |
/alloy:search | Search for tickets by text, status, priority, or assignee |
/alloy:sprint-status | Review active sprint progress and burndown trends |
/alloy:standup | Generate a daily standup: yesterday, today, blockers |
/alloy:assign
Assigns a ticket to a team member. Guides the assistant through identifying the ticket and the assignee, then assigns it via the assign_ticket tool.
Optional arguments:
| Argument | Description |
|---|---|
ticket | Ticket number to assign (e.g., PROJ-42) — skips asking for it |
user | Team member name or email to assign to — skips asking for it |
Behavior:
- If both ticket and user are provided, looks up the user via
list_membersand assigns immediately withassign_ticket - If only ticket is provided, fetches ticket details with
get_ticketand asks who to assign it to - If only user is provided, asks which ticket to assign (can search via
search_tickets) - If neither is provided, asks for both interactively
- Confirms the assignment showing ticket number, title, and assignee
Tools used: assign_ticket, get_ticket, search_tickets, list_members
/alloy:comment
Adds a comment to a ticket. Guides the assistant through identifying the ticket and composing the comment, then posts it via the add_comment tool.
Optional arguments:
| Argument | Description |
|---|---|
ticket | Ticket number to comment on (e.g., PROJ-42) — skips asking for it |
Behavior:
- If ticket is provided, fetches ticket details with
get_ticketfor context, then asks for the comment text - If no ticket is provided, asks the user to identify the ticket first (can search via
search_tickets) - Posts the comment via
add_comment - Confirms the comment was added
Tools used: add_comment, get_ticket, search_tickets
/alloy:create-ticket
Guides you through creating a new ticket by gathering required and optional fields, then calling the create_ticket tool.
Optional arguments:
| Argument | Description |
|---|---|
project_key | Pre-fill the project key (e.g., PROJ) — skips asking for it |
title | Pre-fill the ticket title — skips asking for it |
Behavior:
- Asks for the project key and title (unless pre-filled via arguments)
- Asks about optional fields: description, priority, ticket type, assignee, labels, sprint
- Calls
create_ticketwith all gathered fields - Shows the created ticket number and URL
Tools used: create_ticket
/alloy:invite
Invites a user to the Alloy organization. Guides the assistant through collecting the email address and role, then creates the invite via the create_invite tool.
Optional arguments:
| Argument | Description |
|---|---|
email | Email address of the person to invite |
role | Role to assign: admin, member, or viewer |
Behavior:
- If both email and role are provided, calls
create_inviteimmediately - If only email is provided, asks which role to assign (explains the three options)
- If only role is provided, asks for the email address
- If neither is provided, asks for both interactively
- Confirms the invitation was sent
Tools used: create_invite
/alloy:log-work
Interactively guides you through logging time spent on a ticket.
Arguments: None
Behavior:
- Asks which ticket you worked on — uses
search_ticketsto find it if you provide a number or description - Asks for duration (accepts natural language like “2 hours”, “90 minutes”), date (defaults to today), activity type, and optional description
- Activity types: Coding, Testing, CodeReview, Design, Architecture, PM, Requirements, Training, Maintenance, BugFixing, Documentation, Deployment, Meetings
- Confirms all details before logging
- Calls
log_timewith the collected parameters - Offers to log more time on the same or a different ticket
Supports natural language shortcuts — e.g., “3 hours on PROJ-42 doing code review” extracts all fields automatically.
Tools used: search_tickets, log_time
/alloy:move
Moves a ticket to a new workflow status by guiding you through identifying the ticket and target status, then calling transition_ticket.
Optional arguments:
| Argument | Description |
|---|---|
ticket | Ticket number to move (e.g., PROJ-42) — skips asking for it |
status | Target status (e.g., In Progress, Done) — skips asking for it |
Behavior:
- If ticket and status are both provided, calls
transition_ticketimmediately - If only ticket is provided, fetches current status with
get_ticketand asks for target status - If only status is provided, asks which ticket to move (can search via
search_tickets) - If neither is provided, asks for both ticket and status interactively
- Confirms the transition showing ticket number, title, previous status, and new status
Tools used: transition_ticket, get_ticket, search_tickets
/alloy:my-work
Fetches all tickets assigned to you and presents them grouped by status with actionable highlights.
Arguments: None
Behavior:
- Calls
get_my_ticketsto fetch your assigned tickets - Groups results by status: In Progress, Open/To Do, In Review, Blocked, Done (recent)
- Highlights actionable items: high-priority tickets not yet started, stale tickets (no updates in 7+ days), tickets missing estimates or descriptions
- Ends with a summary: total count, in-progress count, items needing attention
Tools used: get_my_tickets
/alloy:new-project
Creates a new Alloy project. Guides the assistant through collecting the project name and key, then creates it via the create_project tool.
Optional arguments:
| Argument | Description |
|---|---|
name | Name for the new project (e.g., My App) |
key | Short project key (e.g., MYAPP) — uppercase letters, used in ticket IDs |
Behavior:
- If both name and key are provided, calls
create_projectimmediately - If only name is provided, suggests an uppercase key derived from the name and asks for confirmation
- If only key is provided, asks for the project name
- If neither is provided, asks for the project name first, then suggests a key
- Confirms the project was created with its name, key, and URL
Tools used: create_project
/alloy:ping
Checks connectivity to the Alloy MCP server and reports authentication status.
Arguments: None
Behavior:
- Calls the
pingtool to verify server connectivity - Reports which server you are connected to, your authenticated user, and your organisation
- If the ping fails, suggests checking API URL, API key, and network connectivity
Tools used: ping
/alloy:plan-sprint
Guides you through planning an upcoming sprint with backlog review, capacity estimation, and scope selection.
Arguments: None
Behavior:
- Calls
get_project_summaryfor current project and sprint state - Calls
search_ticketsto find backlog items (Open/To Do, not in a sprint) and carryover tickets - Reviews backlog sorted by priority (Critical → Low)
- Asks about team capacity (developers, sprint length)
- Recommends tickets to include based on priority, estimates, and capacity
- Flags dependencies and suggests a balanced mix of features, bugs, and tech debt
- Summarizes proposed sprint plan: goal, selected tickets, effort vs capacity, risks
Tools used: get_project_summary, search_tickets
/alloy:project-summary
Provides a high-level overview of all projects including ticket breakdowns, sprint progress, and items needing attention.
Arguments: None
Behavior:
- Calls
get_project_summaryto fetch all active projects - Presents each project: name, key, ticket breakdown (open vs closed, by priority), sprint status (progress %, days remaining), recent activity
- Highlights items needing attention: projects with no active sprint, sprints ending within 2 days, high ratio of Critical/High open tickets, stale tickets (14+ days)
- Ends with 2–3 actionable recommendations
Tools used: get_project_summary
/alloy:report
Generates a project status report. Aggregates data from project summaries, sprint burndowns, time tracking, and capitalization into a structured report.
Optional arguments:
| Argument | Description |
|---|---|
period | Report period in YYYY-MM format (e.g., 2026-03) or natural language (e.g., this week) |
Behavior:
- If period is provided, gathers data immediately from
get_project_summary,get_sprint_burndown,get_time_report, andget_capitalization_report - If no period is provided, asks the user to specify the reporting period first
- Structures the report into sections: Executive Summary, Sprint Progress, Time & Effort, Key Metrics, and Action Items
Tools used: get_project_summary, get_sprint_burndown, get_time_report, get_capitalization_report
/alloy:search
Searches for tickets by text, status, priority, assignee, labels, or project.
Optional arguments:
| Argument | Description |
|---|---|
query | Search query text (e.g., auth bug, high priority) — searches immediately |
Behavior:
- If query is provided, calls
search_ticketsimmediately with the query - If no query, asks what the user is looking for (text, status, priority, assignee, labels, project)
- Presents results in a tabular format: ticket number, title, status, priority, assignee
Tools used: search_tickets
/alloy:sprint-status
Reviews the status of all active sprints across projects, including burndown analysis and risk assessment.
Arguments: None
Behavior:
- Calls
get_project_summaryto identify active projects and sprints - Calls
get_sprint_burndownfor each active sprint to get progress metrics - Summarizes each sprint: name, date range, completed vs total points, burndown trend, tickets by status, at-risk items
- Provides an overall assessment with recommended actions (re-scope, reassign, escalate)
Tools used: get_project_summary, get_sprint_burndown
/alloy:standup
Generates a 3-part daily standup summary based on your assigned tickets and recent activity.
Arguments: None
Behavior:
- Calls
get_my_ticketsto fetch your assigned tickets - Yesterday: Identifies tickets recently moved to Done or In Review, or with activity in the last 24 hours
- Today: Lists In Progress or high-priority Open tickets as planned focus
- Blockers: Flags blocked tickets, stale tickets (no updates in 3+ days while In Progress), or unresolved dependencies
Output is kept under 15 lines for quick reading.
Tools used: get_my_tickets
Error Handling
When a tool call fails, the MCP server returns an error with a descriptive message.
Common Error Patterns
| Error | Cause | Resolution |
|---|---|---|
Failed to connect to Alloy API: ... | API server unreachable | Verify ALLOY_API_URL and that the server is running |
Permission denied: ... | Insufficient permissions | Check your API key scopes and org role |
Not found | Invalid UUID or deleted resource | Verify the ID exists using a search or list tool |
Transition failed: ... | Invalid workflow transition | Check the error message for available transitions from the current status |
Validation error: ... | Invalid input (e.g., title too long) | Fix the input according to the parameter constraints |
Error Response Format
Tool errors are returned as MCP error objects:
{
"code": -32603,
"message": "Not found: Ticket with id 770e8400-... does not exist"
}
The transition_ticket tool provides enhanced error messages that include the current status and list of valid transitions when a transition fails.
Projects & Tickets
Projects group related work under a short key (e.g. BACK, WEB). Tickets
represent individual tasks, bugs, or features within a project. This guide
covers creating, listing, updating, and managing both.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-pt@alloy.dev",
"password": "guide-pt-pass1",
"display_name": "PT Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-pt@alloy.dev",
"display_name": "PT Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Guide Org",
"slug": "guide-pt-org"
}' | jq .
{
"id": "...",
"name": "Guide Org",
"slug": "guide-pt-org"
}
ORG_ID="<id from above>"
2. Create a Project
Every project belongs to an organization and has a unique short key used in
ticket references (e.g. BACK-1, WEB-42).
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"GUIDE\",
\"name\": \"Guide Project\",
\"description\": \"A project for the guide walkthrough\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "GUIDE",
"name": "Guide Project",
"description": "A project for the guide walkthrough",
"ticket_counter": 0
}
Save the project ID:
PROJECT_ID="<id from above>"
CLI shortcut:
alloy project create --org-id "$ORG_ID" --key GUIDE --name "Guide Project"
3. List Projects
List all projects in your organization:
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"key": "GUIDE",
"name": "Guide Project"
}
],
"next_cursor": null,
"has_more": false
}
Use limit and cursor for pagination:
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID&limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"key": "GUIDE",
"name": "Guide Project"
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy project list --org-id "$ORG_ID"
4. Get & Look Up Projects
Fetch a project by ID:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"org_id": "...",
"key": "GUIDE",
"name": "Guide Project",
"description": "A project for the guide walkthrough"
}
Or look it up by key:
curl -s "$BASE_URL/api/v1/projects/key/GUIDE?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"key": "GUIDE",
"name": "Guide Project"
}
CLI shortcut:
alloy project get "$PROJECT_ID"
5. Update a Project
Rename a project or change its description with a PATCH request. Only include the fields you want to change:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Guide Project (Updated)", "description": "Updated description"}' | jq .
{
"id": "...",
"key": "GUIDE",
"name": "Guide Project (Updated)",
"description": "Updated description"
}
CLI shortcut:
alloy project update "$PROJECT_ID" --name "Guide Project (Updated)"
6. Create Tickets
Tickets live inside a project. Each ticket gets an auto-incrementing number
combined with the project key (e.g. GUIDE-1):
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Set up CI pipeline\",
\"description\": \"Configure GitHub Actions for the project\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Set up CI pipeline",
"description": "Configure GitHub Actions for the project",
"status": "Backlog",
"priority": "High",
"reporter_id": "..."
}
Save the ticket ID:
TICKET_ID="<id from above>"
Fetch the ticket back by ID:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "High"
}
Resolve a ticket by key-number reference (e.g. GUIDE-1):
curl -s "$BASE_URL/api/v1/tickets/resolve?ref=GUIDE-1&org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"ticket": {
"id": "...",
"ticket_number": 1,
"title": "Set up CI pipeline"
},
"suggestions": []
}
CLI shortcut:
alloy ticket create --project "$PROJECT_ID" --title "Set up CI pipeline" --priority high alloy ticket get GUIDE-1
7. List & Filter Tickets
Create a second ticket so there is something to filter against:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Write API docs\",
\"description\": \"Document all REST endpoints\",
\"priority\": \"Medium\",
\"status\": \"Todo\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 2,
"title": "Write API docs",
"description": "Document all REST endpoints",
"status": "Todo",
"priority": "Medium",
"reporter_id": "..."
}
List all tickets in a project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"title": "...",
"status": "...",
"priority": "..."
}
],
"next_cursor": null,
"has_more": false
}
Filter by status:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?status=Backlog" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"ticket_number": 1,
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "High"
}
],
"next_cursor": null,
"has_more": false
}
Filter by priority:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?priority=Medium" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"ticket_number": 2,
"title": "Write API docs",
"status": "Todo",
"priority": "Medium"
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy ticket list --project "$PROJECT_ID" --status backlog
8. Update & Transition Tickets
Update a ticket’s fields:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"priority\": \"Urgent\", \"assignee_id\": \"$USER_ID\"}" | jq .
{
"id": "...",
"ticket_number": 2,
"title": "Write API docs",
"status": "Todo",
"priority": "Urgent",
"assignee_id": "..."
}
Transition a ticket to a new status:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "...",
"ticket_number": 2,
"title": "Write API docs",
"status": "InProgress",
"priority": "Urgent"
}
Check which transitions are available from the current status:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"current_status": "InProgress",
"available": [
{
"name": "...",
"category": "..."
}
]
}
CLI shortcut:
alloy ticket update GUIDE-2 --priority urgent --assignee-id "$USER_ID"
Learn More
- Workflows & Statuses — customize ticket lifecycle transitions
- Sprints & Boards — plan and track work in time-boxed iterations
- Labels, Tags & Organization — categorize and filter tickets
- Teams, Roles & Permissions — control who can create and edit tickets
- End-to-End Walkthrough — see projects and tickets in a full workflow
Sprints & Boards
Sprints represent time-boxed iterations within a project. Each sprint moves through a lifecycle: Planned → Active → Completed. The board view groups a sprint’s tickets by status, and the burndown chart tracks daily progress. This guide walks through the full sprint lifecycle.
1. Prerequisites
You need a running Alloy server with a project that has tickets. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-sb@alloy.dev",
"password": "guide-sb-pass1",
"display_name": "SB Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-sb@alloy.dev",
"display_name": "SB Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization and project:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint Guide Org",
"slug": "guide-sb-org"
}' | jq .
{
"id": "...",
"name": "Sprint Guide Org",
"slug": "guide-sb-org"
}
ORG_ID="<id from above>"
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"SB\",
\"name\": \"Sprint Board Project\",
\"description\": \"Project for the sprints guide\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "SB",
"name": "Sprint Board Project",
"description": "Project for the sprints guide",
"ticket_counter": 0
}
PROJECT_ID="<id from above>"
Create a couple of tickets to work with:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Design landing page\",
\"description\": \"Create the main landing page layout\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Design landing page",
"description": "Create the main landing page layout",
"status": "Backlog",
"priority": "High",
"reporter_id": "..."
}
TICKET_ID="<id from above>"
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Set up database schema\",
\"description\": \"Define initial tables and migrations\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 2,
"title": "Set up database schema",
"description": "Define initial tables and migrations",
"status": "Backlog",
"priority": "Medium",
"reporter_id": "..."
}
2. Create a Sprint
Sprints belong to a project and require a name, start date, and end date. New sprints start in the Planned status.
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 1",
"goal": "Complete MVP foundations",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "Complete MVP foundations",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Save the sprint ID:
SPRINT_ID="<id from above>"
The goal field is optional and describes what the sprint aims to achieve.
CLI shortcut:
alloy sprint create --project "$PROJECT_ID" --name "Sprint 1" --goal "Complete MVP foundations" --start-date 2026-04-01 --end-date 2026-04-15
3. List Sprints
List all sprints in a project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "Complete MVP foundations",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Use limit and cursor for pagination when you have many sprints:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints?limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"name": "Sprint 1",
"status": "Planned"
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy sprint list --project "$PROJECT_ID"
4. Get & Update a Sprint
Fetch a single sprint by ID:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "Complete MVP foundations",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Update the sprint goal or dates with a PATCH request. Only include the fields you want to change:
curl -s -X PATCH "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"goal": "Ship auth and landing page"}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "Ship auth and landing page",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
You can also update the name, start_date, and end_date. Pass null for
the goal field to clear it.
CLI shortcut:
alloy sprint get "$SPRINT_ID" alloy sprint update "$SPRINT_ID" --goal "Ship auth and landing page"
5. Start a Sprint
Transition a sprint from Planned to Active. Only one sprint per project should typically be active at a time.
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
The status changes to Active. Starting a sprint that is already active or
completed returns an error.
CLI shortcut:
alloy sprint start "$SPRINT_ID"
6. View the Sprint Board
The board endpoint groups a sprint’s tickets by their workflow status, giving you a kanban-style view of the sprint.
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/board" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint": {
"id": "...",
"project_id": "...",
"name": "...",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "...",
"created_at": "...",
"updated_at": "..."
},
"columns": [
{
"status": "...",
"tickets": []
},
{
"status": "...",
"tickets": []
},
{
"status": "...",
"tickets": []
}
]
}
Each column represents a workflow status. Tickets in the sprint appear under their current status column. Move tickets between columns by transitioning their status:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "InProgress",
"priority": "..."
}
CLI shortcut:
alloy ticket transition "$TICKET_ID" --to-status InProgress
7. Track Progress with Burndown
The burndown endpoint returns daily progress data for a sprint, showing how many tickets remain over time:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/burndown" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint_id": "...",
"data": [
{
"date": "...",
"total_tickets": 0,
"completed_tickets": 0,
"remaining_tickets": 0
}
]
}
Each entry in the data array represents one day and contains:
| Field | Description |
|---|---|
date | The calendar date (ISO 8601) |
total_tickets | Total tickets assigned to the sprint on that day |
completed_tickets | Tickets in a done status category on that day |
remaining_tickets | total_tickets - completed_tickets |
Use this data to plot a burndown chart and spot trends early. A flat or rising
remaining_tickets line signals the sprint may be at risk.
8. Complete a Sprint
When the iteration is over, transition the sprint from Active to Completed:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/complete" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "Completed",
"created_at": "...",
"updated_at": "..."
}
The status changes to Completed. Any tickets still in progress remain in their
current status — they are not automatically moved. You can reassign
unfinished tickets to the next sprint.
CLI shortcut:
alloy sprint complete "$SPRINT_ID"
9. Delete a Sprint
Admins can delete a sprint. If the sprint has tickets assigned, the API returns
a 409 Conflict unless you pass ?cascade=true.
Preview what would be deleted with a dry-run:
curl -s -X DELETE "$BASE_URL/api/v1/sprints/$SPRINT_ID?cascade=true&dry_run=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint_id": "...",
"dependents": {
"tickets": 0
}
}
Then delete the sprint. With no dependents, a simple DELETE returns 204 No Content. If tickets are assigned, add ?cascade=true to remove them as well.
Note: Deleting a sprint is irreversible. Always use the dry-run option first to review what will be removed.
Learn More
- Projects & Tickets — create and manage the tickets that fill your sprints
- Workflows & Statuses — define the statuses tickets move through during a sprint
- Time Tracking & Finance — log hours against sprint tickets
- Running a Sprint (Playbook) — step-by-step sprint lifecycle from a PM’s perspective
Workflows & Statuses
Workflows define the set of statuses a ticket can move through and which transitions are allowed between them. Each organization gets a Default workflow automatically, but you can create custom workflows with strict enforcement to prevent invalid state changes. This guide walks through the full workflow lifecycle: creating, assigning, transitioning, and enforcing.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-wf@alloy.dev",
"password": "guide-wf-pass1",
"display_name": "WF Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-wf@alloy.dev",
"display_name": "WF Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Workflow Guide Org",
"slug": "guide-wf-org"
}' | jq .
{
"id": "...",
"name": "Workflow Guide Org",
"slug": "guide-wf-org"
}
ORG_ID="<id from above>"
Create a project to use for ticket transitions later:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"WF\",
\"name\": \"Workflow Demo Project\",
\"description\": \"Project for the workflows guide\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "WF",
"name": "Workflow Demo Project",
"description": "Project for the workflows guide",
"ticket_counter": 0
}
PROJECT_ID="<id from above>"
2. The Default Workflow
Every organization gets a Default workflow when it is created. This
workflow has six statuses and allows any-to-any transitions (enforcement
mode none).
List the workflows in your organization:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"name": "Default",
"statuses": [
{ "name": "Backlog", "category": "backlog" },
{ "name": "Todo", "category": "todo" },
{ "name": "InProgress", "category": "in_progress" },
{ "name": "InReview", "category": "in_review" },
{ "name": "Done", "category": "done" },
{ "name": "Cancelled", "category": "cancelled" }
],
"transitions": "...",
"enforcement": "none",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
The default statuses and their categories:
| Status | Category | Meaning |
|---|---|---|
| Backlog | backlog | Captured but not yet planned |
| Todo | todo | Planned and ready to start |
| InProgress | in_progress | Actively being worked on |
| InReview | in_review | Work complete, awaiting review |
| Done | done | Finished |
| Cancelled | cancelled | Will not be done |
With enforcement: "none", tickets can move freely between any statuses.
3. Create a Custom Workflow
Create a workflow with specific statuses, allowed transitions, and strict enforcement so that only defined transitions are permitted.
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Bug Triage",
"statuses": [
{ "name": "New", "category": "backlog" },
{ "name": "Triaged", "category": "todo" },
{ "name": "Fixing", "category": "in_progress" },
{ "name": "Verifying", "category": "in_review" },
{ "name": "Closed", "category": "done" },
{ "name": "WontFix", "category": "cancelled" }
],
"transitions": [
{ "from": "New", "to": "Triaged" },
{ "from": "New", "to": "WontFix" },
{ "from": "Triaged", "to": "Fixing" },
{ "from": "Triaged", "to": "WontFix" },
{ "from": "Fixing", "to": "Verifying" },
{ "from": "Fixing", "to": "Triaged" },
{ "from": "Verifying", "to": "Closed" },
{ "from": "Verifying", "to": "Fixing" }
],
"enforcement": "strict"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": [
{ "name": "New", "category": "backlog" },
{ "name": "Triaged", "category": "todo" },
{ "name": "Fixing", "category": "in_progress" },
{ "name": "Verifying", "category": "in_review" },
{ "name": "Closed", "category": "done" },
{ "name": "WontFix", "category": "cancelled" }
],
"transitions": [
{ "from": "New", "to": "Triaged" },
{ "from": "New", "to": "WontFix" },
{ "from": "Triaged", "to": "Fixing" },
{ "from": "Triaged", "to": "WontFix" },
{ "from": "Fixing", "to": "Verifying" },
{ "from": "Fixing", "to": "Triaged" },
{ "from": "Verifying", "to": "Closed" },
{ "from": "Verifying", "to": "Fixing" }
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Save the workflow ID:
WORKFLOW_ID="<id from above>"
The enforcement field controls how strictly transitions are validated:
| Mode | Invalid Transition | HTTP Status |
|---|---|---|
| none | Allowed silently | 200 |
| warn | Allowed with warning in response | 200 |
| strict | Rejected | 422 |
4. Get & Update a Workflow
Fetch a single workflow by ID:
curl -s "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": "...",
"transitions": "...",
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Update the workflow name or enforcement mode with a PATCH request. Only include the fields you want to change:
curl -s -X PATCH "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Bug Triage v2"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage v2",
"statuses": "...",
"transitions": "...",
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
You can also update statuses, transitions, and enforcement in a single
PATCH call. Updating statuses replaces the full list, so always include all
statuses you want to keep.
CLI shortcut:
alloy workflow get "$WORKFLOW_ID" alloy workflow update "$WORKFLOW_ID" --name "Bug Triage v2"
5. Assign a Workflow to a Project
Projects without a workflow use the organization’s Default workflow.
To assign a custom workflow, update the project’s workflow_id. Currently
this is done when creating the project or through the Alloy MCP tools:
# Via MCP tool
alloy project update "$PROJECT_ID" --workflow-id "$WORKFLOW_ID"
Once assigned, all ticket transitions within that project are validated against the custom workflow’s rules and enforcement mode.
6. Transition Tickets
Create a ticket in the project, then transition it through statuses. With
the Default workflow (enforcement none), any transition is allowed:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Fix login timeout\",
\"description\": \"Users report session expiring too early\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Fix login timeout",
"description": "Users report session expiring too early",
"status": "Backlog",
"priority": "High",
"reporter_id": "..."
}
TICKET_ID="<id from above>"
Transition the ticket to InProgress:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Fix login timeout",
"status": "InProgress",
"priority": "High"
}
Check which transitions are available from the current status:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"current_status": "InProgress",
"available": [
{
"name": "...",
"category": "..."
}
]
}
With enforcement: "none", all statuses appear as available transitions.
With stricter enforcement, only explicitly defined transitions appear.
CLI shortcut:
alloy ticket transition "$TICKET_ID" --to-status InProgress
7. Strict Mode — Rejected Transitions
When a workflow uses enforcement: "strict", transitions that are not
explicitly defined in the workflow’s transitions list are rejected with
a 422 Unprocessable Entity error.
For example, in the “Bug Triage” workflow created above, going directly from New to Closed is not allowed (you must go through Triaged → Fixing → Verifying → Closed).
Attempting an invalid transition returns a 422:
curl -s -w "\nHTTP_STATUS: %{http_code}\n" \
-X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "Done"}' | head -20
With strict enforcement active, the response would be:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "..."
}
}
The ticket stays in its current status. The caller must follow the defined transition path.
Warn mode is a middle ground: invalid transitions succeed but the
response includes a warning field alerting the caller:
{
"id": "...",
"status": "Done",
"warning": "..."
}
This lets teams gradually adopt workflow rules without blocking anyone.
8. Delete a Workflow
Admins can delete a workflow. If projects reference the workflow, the API
returns a 409 Conflict error:
curl -s -X DELETE "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content. To delete a workflow that is in use, first
reassign or remove the workflow from all projects that reference it.
CLI shortcut:
alloy workflow delete "$WORKFLOW_ID"
Learn More
- Projects & Tickets — create tickets that follow your workflow statuses
- Sprints & Boards — move tickets through statuses during sprint execution
- Labels, Tags & Organization — complement workflows with labels for categorization
- For Engineering Managers — workflow customization from an EM’s perspective
Time Tracking & Finance
Alloy includes built-in time tracking, approval workflows, labor-rate management, and capitalization reporting. Engineers log time against tickets, managers approve entries, and finance teams generate ASC 350-40 / IAS 38 compliant reports — all through the same API. This guide covers the full lifecycle from logging your first hour to exporting a capitalization CSV.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-tf@alloy.dev",
"password": "guide-tf-pass1",
"display_name": "TF Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-tf@alloy.dev",
"display_name": "TF Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Finance Org",
"slug": "guide-tf-org"
}' | jq .
{
"id": "...",
"name": "Finance Org",
"slug": "guide-tf-org"
}
ORG_ID="<id from above>"
Create a project with finance fields:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"FIN\",
\"name\": \"Finance Feature\",
\"description\": \"Project for time tracking guide\",
\"capitalization_type\": \"Capex\",
\"development_phase\": \"AppDevelopment\",
\"cost_center_id\": \"ENG-001\",
\"budget_cents\": 5000000,
\"budget_period\": \"Quarterly\",
\"amortization_months\": 36
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "FIN",
"name": "Finance Feature",
"description": "Project for time tracking guide",
"ticket_counter": 0,
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-001",
"budget_cents": 5000000,
"budget_period": "Quarterly",
"amortization_months": 36
}
PROJECT_ID="<id from above>"
Create a ticket to log time against:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Implement SSO login\",
\"description\": \"Add SAML-based single sign-on\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Implement SSO login",
"description": "Add SAML-based single sign-on",
"status": "...",
"assignee_id": null,
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
TICKET_ID="<id from above>"
2. Understanding Activity Types
Every time entry requires an activity_type that classifies the work
performed. Alloy defines 13 activity types used for reporting and
capitalization accounting:
| Activity Type | Description | CapEx Eligible |
|---|---|---|
Coding | Writing or modifying application code | Yes |
Testing | Writing or running tests | Yes |
CodeReview | Reviewing pull requests and code | Yes |
Design | UI/UX design work | Yes |
Architecture | System design and architecture decisions | Yes |
PM | Project management tasks | Depends on phase |
Requirements | Gathering and documenting requirements | Planning phase only |
Training | Learning, onboarding, or training activities | No |
Maintenance | Infrastructure and dependency maintenance | No (OpEx) |
BugFixing | Investigating and fixing bugs | Yes (Development phase) |
Documentation | Writing or updating documentation | Yes |
Deployment | Deploying, releasing, or CI/CD work | Yes |
Meetings | Meetings, standups, and ceremonies | Depends on context |
Activity types map to capitalization categories in reports. Work classified
as Coding, Testing, or Design during a Development phase project
is typically capitalizable under ASC 350-40.
3. Creating Time Entries
Log time against a ticket with a specific activity type and duration:
curl -s -X POST "$BASE_URL/api/v1/time-entries" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"ticket_id\": \"$TICKET_ID\",
\"project_id\": \"$PROJECT_ID\",
\"date\": \"2026-03-28\",
\"duration_minutes\": 120,
\"description\": \"Implemented SAML flow\",
\"activity_type\": \"Coding\"
}" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 120,
"description": "Implemented SAML flow",
"activity_type": "Coding",
"status": "Draft",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
TIME_ENTRY_ID="<id from above>"
New entries always start in Draft status. You can log additional entries
with different activity types (e.g. CodeReview, Testing) against the
same ticket.
4. Viewing Time Entries
Retrieve a single time entry by ID:
curl -s "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 120,
"description": "Implemented SAML flow",
"activity_type": "Coding",
"status": "Draft",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
List all time entries for a ticket:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/time-entries" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 120,
"description": "Implemented SAML flow",
"activity_type": "Coding",
"status": "Draft",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
List all time entries for a user:
curl -s "$BASE_URL/api/v1/users/$USER_ID/time-entries" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "...",
"duration_minutes": 120,
"description": "...",
"activity_type": "...",
"status": "...",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Both list endpoints support cursor and limit query parameters for
pagination, plus optional project_id and date filters.
5. Editing Time Entries
Update a draft time entry before submitting it. You can change the date, duration, description, or activity type:
curl -s -X PATCH "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"duration_minutes": 150,
"description": "Implemented SAML flow and wrote integration tests"
}' | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 150,
"description": "Implemented SAML flow and wrote integration tests",
"activity_type": "Coding",
"status": "Draft",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
To delete a time entry entirely:
curl -s -X DELETE "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content on success.
6. Submitting for Approval
When your time entries are ready, submit them for manager review. This
transitions the entry from Draft to Submitted:
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/submit" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 150,
"description": "Implemented SAML flow and wrote integration tests",
"activity_type": "Coding",
"status": "Submitted",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
Once submitted, the entry cannot be edited until it is either approved or rejected. The status lifecycle is:
Draft → Submitted → Approved
→ Rejected
7. Approving Time Entries
Managers and admins can list all submitted entries awaiting approval:
curl -s "$BASE_URL/api/v1/time-entries/submitted" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 150,
"description": "Implemented SAML flow and wrote integration tests",
"activity_type": "Coding",
"status": "Submitted",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Approve a submitted entry (requires Admin role):
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/approve" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 150,
"description": "Implemented SAML flow and wrote integration tests",
"activity_type": "Coding",
"status": "Approved",
"approved_by": "...",
"approved_at": "...",
"created_at": "...",
"updated_at": "..."
}
Only Approved time entries are included in capitalization reports.
8. Setting Labor Rates
Labor rates define the loaded cost per hour for each user, used in capitalization calculations. Rates require Admin or Owner role.
Create a labor rate:
curl -s -X POST "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 15000,
\"effective_date\": \"2026-01-01\"
}" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 15000,
"effective_date": "2026-01-01",
"created_at": "...",
"updated_at": "..."
}
LABOR_RATE_ID="<id from above>"
A loaded_rate_cents of 15000 means $150.00 per hour. Rates take effect
from the effective_date forward. You can set multiple rates over time to
track salary changes:
curl -s -X POST "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 16000,
\"effective_date\": \"2026-07-01\"
}" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 16000,
"effective_date": "2026-07-01",
"created_at": "...",
"updated_at": "..."
}
Get the current effective rate for a user:
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates/current" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 15000,
"effective_date": "2026-01-01",
"created_at": "...",
"updated_at": "..."
}
List all historical rates:
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 16000,
"effective_date": "2026-07-01",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 15000,
"effective_date": "2026-01-01",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
9. Generating Capitalization Reports
Capitalization reports aggregate approved time entries with labor rates, grouped by project and activity type. They support ASC 350-40 / IAS 38 software cost accounting.
Generate a report for a specific month:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": [
{
"project_id": "...",
"project_key": "FIN",
"project_name": "Finance Feature",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-001",
"total_hours": 2.5,
"total_amount_cents": 37500,
"breakdown": [
{
"activity_type": "Coding",
"hours": 2.5,
"amount_cents": 37500
}
]
}
]
}
Include budget tracking with include_budget=true:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": [
{
"project_id": "...",
"project_key": "FIN",
"project_name": "Finance Feature",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-001",
"total_hours": 2.5,
"total_amount_cents": 37500,
"budget_cents": 5000000,
"budget_period": "Quarterly",
"spent_cents": 37500,
"budget_remaining_cents": 4962500,
"budget_utilization_pct": 0.75,
"breakdown": [
{
"activity_type": "Coding",
"hours": 2.5,
"amount_cents": 37500
}
]
}
]
}
Group results by team or user:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&group_by=user" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"users": [
{
"user_id": "...",
"display_name": "TF Guide User",
"total_hours": 2.5,
"total_amount_cents": 37500,
"projects": [
{
"project_id": "...",
"project_key": "FIN",
"project_name": "Finance Feature",
"total_hours": 2.5,
"total_amount_cents": 37500,
"breakdown": [
{
"activity_type": "Coding",
"hours": 2.5,
"amount_cents": 37500
}
]
}
]
}
]
}
Filter by cost center, activity type, or tag:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&cost_center_id=ENG-001&activity_type=Coding" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": [
{
"project_id": "...",
"project_key": "FIN",
"project_name": "Finance Feature",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-001",
"total_hours": 2.5,
"total_amount_cents": 37500,
"breakdown": [
{
"activity_type": "Coding",
"hours": 2.5,
"amount_cents": 37500
}
]
}
]
}
10. Exporting and Analyzing Data
Export the capitalization report as CSV for spreadsheet analysis or ERP import:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03" \
-H "Authorization: Bearer $TOKEN" -o report.csv
The CSV includes columns:
Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization
Filter the export by tag for department-level reporting:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03&tag=department:engineering" \
-H "Authorization: Bearer $TOKEN" -o engineering-report.csv
Tag time entries for additional reporting dimensions. First, tag a time entry:
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/time_entry/$TIME_ENTRY_ID/tags" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tags": [
{"key": "billable", "value": "true"},
{"key": "client", "value": "acme-corp"}
]
}' | jq .
[
{
"id": "...",
"org_id": "...",
"entity_type": "time_entry",
"entity_id": "...",
"key": "billable",
"value": "true",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"org_id": "...",
"entity_type": "time_entry",
"entity_id": "...",
"key": "client",
"value": "acme-corp",
"created_at": "...",
"updated_at": "..."
}
]
Search for all entities with a specific tag:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/tags/search?key=billable&value=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"entity_type": "time_entry",
"entity_id": "...",
"key": "billable",
"value": "true",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut: alloy time log --ticket FIN-1 --duration 2h --type Coding
MCP tools: Use log_time, get_time_report, submit_time_entry,
approve_time_entry, and get_capitalization_report for programmatic
access through AI assistants.
Learn More
- Projects & Tickets — create the tickets you log time against
- Sprints & Boards — track time within sprint iterations
- Teams, Roles & Permissions — control who can approve time entries
- Quarterly Finance Reporting (Playbook) — step-by-step quarterly close process for finance teams
- For Finance Teams — role-based guide for CFOs and finance leads
Teams, Roles & Permissions
Every Alloy organization has a role-based access control system with five roles: Owner, Admin, Member, Reporter, and Viewer. Teams let you group users for project assignment and time-tracking reports. This guide walks through inviting users, managing roles, assigning project members, and handling permission boundaries.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-tp@alloy.dev",
"password": "guide-tp-pass1",
"display_name": "TP Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-tp@alloy.dev",
"display_name": "TP Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Permissions Guide Org",
"slug": "guide-tp-org"
}' | jq .
{
"id": "...",
"name": "Permissions Guide Org",
"slug": "guide-tp-org"
}
ORG_ID="<id from above>"
2. Roles Overview
Every organization member has exactly one role. Roles are hierarchical — each role inherits all permissions of the roles below it.
| Role | Description |
|---|---|
| Owner | Full control. Created automatically for the org creator. |
| Admin | Manage workflows, teams, invites, delete resources. |
| Member | Create and update projects, tickets, sprints, time entries. |
| Reporter | Create tickets and comments. Limited to assigned projects. |
| Viewer | Read-only access to all resources. |
Full Permission Matrix
| Operation | Minimum Role |
|---|---|
| Orgs | |
| Create / update org | Owner |
| Invites | |
| Create / revoke invite | Admin |
| List invites | Any authenticated |
| Workflows | |
| Create / update / delete workflow | Admin |
| Teams | |
| Delete team | Admin |
| Projects | |
| Create / update project | Member |
| Delete project | Admin |
| Project Members | |
| Add / remove project member | Member |
| List project members | Any authenticated |
| Tickets | |
| Create ticket | Reporter (must be assigned to the project) |
| Update / transition ticket | Member |
| Delete ticket | Admin |
| Comments | |
| Create comment | Reporter |
| Update / delete comment | Author or Admin |
| Sprints | |
| Create / update / start / complete sprint | Member |
| Delete sprint | Admin |
| Time Entries | |
| Create / update / delete / submit time entry | Member |
| Approve time entry | Admin |
| Labor Rates | |
| All labor rate operations | Admin |
3. Invite a User
Invites let you bring new users into your organization with a specific role. Only Admins and above can create invites.
Create an invite for a new team member:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"email\": \"alice-tp@alloy.dev\", \"role\": \"member\", \"created_by\": \"$USER_ID\"}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "alice-tp@alloy.dev",
"role": "Member",
"expires_at": "..."
}
The response includes an invite_code that the recipient uses when
registering. The invite_link is a ready-to-share URL.
INVITE_CODE="<invite_code from above>"
CLI shortcut:
alloy org invite create --org "$ORG_ID" --email alice-tp@alloy.dev --role member
4. List Invites
View all pending invites for the organization. Any authenticated member can list invites:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"email": "alice-tp@alloy.dev",
"invite_code": "...",
"role": "Member",
"created_by": "...",
"expires_at": "...",
"accepted_at": null,
"revoked_at": null,
"created_at": "..."
}
]
5. Register with an Invite Code
A user who receives an invite registers with the invite code to automatically join the organization with the assigned role:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d "{
\"email\": \"alice-tp@alloy.dev\",
\"password\": \"alice-tp-pass1\",
\"display_name\": \"Alice Member\",
\"invite_code\": \"$INVITE_CODE\"
}" | jq .
{
"user_id": "...",
"email": "alice-tp@alloy.dev",
"display_name": "Alice Member",
"access_token": "..."
}
The new user is now a Member of the organization. In a real workflow you would save Alice’s credentials separately from the Owner’s token.
6. List Organization Members
Verify that both the Owner and the invited user appear in the member list:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"user_id": "...",
"display_name": "...",
"email": "...",
"role": "...",
"joined_at": "..."
}
]
}
The org creator has role Owner. Alice (who registered with the invite code)
has role member.
CLI shortcut:
alloy org members list --org "$ORG_ID"
7. Create a Project and Add Members
Create a project, then add users as project members. Project membership controls which projects Reporters can access (see Section 8).
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"PERM\",
\"name\": \"Permissions Demo\",
\"description\": \"Project for the permissions guide\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "PERM",
"name": "Permissions Demo",
"description": "Project for the permissions guide",
"ticket_counter": 0
}
Add a user as a project member:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"user_id\": \"$USER_ID\"}" | jq .
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
List project members:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
]
CLI shortcut:
alloy project members add --project "$PROJECT_ID" --user "$USER_ID" alloy project members list --project "$PROJECT_ID"
8. Reporter Scoping
Reporters only see projects they are explicitly assigned to as project
members. When a Reporter calls GET /api/v1/projects, only assigned projects
are returned. Attempting to access an unassigned project returns 403.
This scoping is silent — the Reporter receives fewer results rather than an error when listing. This makes it safe to give Reporters broad read access while limiting their view to relevant projects.
| Role | Project Visibility |
|---|---|
| Member and above | All projects in the organization |
| Reporter | Only projects where they are a project member |
| Viewer | All projects (read-only) |
To grant a Reporter access, add them as a project member (see Section 7).
9. Handling Forbidden Actions
When a user tries to perform an action above their role, the API returns a
403 Forbidden error with a JSON body explaining which role is required.
For example, a Viewer attempting to create a project:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $VIEWER_TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"DENY\",
\"name\": \"Should Fail\"
}" | jq .
The API responds with a 403 and a structured error body:
{
"error": {
"code": "FORBIDDEN",
"message": "requires Member role or above",
"details": []
}
}
The response body always includes a machine-readable code and a
human-readable message indicating the minimum role needed.
Common permission errors and their causes:
| HTTP Status | Meaning |
|---|---|
| 401 | Missing or invalid authentication token |
| 403 | Role is insufficient for the requested operation |
| 404 | Resource not found (or hidden by tenant isolation) |
Tip: Use curl -w "\nHTTP_STATUS: %{http_code}\n" to see the status
code alongside the response body when debugging permission issues.
10. Best Practices
- Start with the least privilege. Invite new users as Reporters or Viewers, then promote as needed.
- Use project membership for scoping. Rather than creating separate organizations, use project membership to control which Reporters see which work.
- Audit with the members list. Regularly review
GET /orgs/{id}/membersto ensure role assignments are up to date. - Prefer invites over direct adds. Invites create an audit trail and let the recipient choose their own password.
- Revoke unused invites. Delete pending invites with
DELETE /orgs/{id}/invites/{invite_id}when they are no longer needed. - Use SCIM for large teams. If your identity provider supports SCIM 2.0,
use the
/scim/v2/endpoints to automate user and group provisioning instead of managing invites manually.
Learn More
- Projects & Tickets — manage the work your team members collaborate on
- Time Tracking & Finance — track hours and labor costs per team member
- Setting Up a New Team (Playbook) — end-to-end guide for onboarding a new team
- For Engineering Managers — role-based guide for managing engineering teams
Labels, Tags & Organization
Labels are colored markers you attach to tickets for visual categorization
(e.g. bug, feature, urgent). Tags are arbitrary key-value pairs you attach
to any entity (projects, tickets, users, teams, time entries) for filtering and
reporting. This guide covers creating, assigning, searching, and filtering with
both.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-lt@alloy.dev",
"password": "guide-lt-pass1",
"display_name": "LT Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-lt@alloy.dev",
"display_name": "LT Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "LT Guide Org",
"slug": "guide-lt-org"
}' | jq .
{
"id": "...",
"name": "LT Guide Org",
"slug": "guide-lt-org"
}
ORG_ID="<id from above>"
Create a project and a ticket for the examples that follow:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"LTAG\",
\"name\": \"Labels Tags Demo\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "LTAG",
"name": "Labels Tags Demo",
"ticket_counter": 0
}
PROJECT_ID="<id from above>"
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Tag demo ticket\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Tag demo ticket",
"status": "Backlog",
"priority": "Medium",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
TICKET_ID="<id from above>"
2. Create Labels
Labels belong to an organization and have a name and hex color. Create a few labels to categorize tickets:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "bug", "color": "#FF0000"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF0000",
"created_at": "...",
"updated_at": "..."
}
Create a second label:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "feature", "color": "#00FF00"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "feature",
"color": "#00FF00",
"created_at": "...",
"updated_at": "..."
}
CLI shortcut:
alloy label create --org $ORG_ID --name bug --color "#FF0000"
3. List & Get Labels
List all labels in your organization:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Fetch a single label by ID:
curl -s "$BASE_URL/api/v1/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
4. Update & Delete Labels
Rename a label or change its color with a PATCH:
curl -s -X PATCH "$BASE_URL/api/v1/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "critical-bug", "color": "#CC0000"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "critical-bug",
"color": "#CC0000",
"created_at": "...",
"updated_at": "..."
}
To delete a label that is not in use, send a DELETE request. If tickets
reference the label, use ?cascade=true to remove all associations first
(or ?cascade=true&dry_run=true to preview the impact).
5. Assign Labels to Tickets
Add a label to a ticket:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
]
List all labels on a ticket:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
]
To replace all labels on a ticket at once, POST a label_ids array:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"label_ids\": [\"$LABEL_ID\"]}" | jq .
[
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
]
To remove a single label from a ticket, send a DELETE:
curl -s -X DELETE "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN"
This returns 204 No Content.
CLI shortcut:
alloy ticket label add $TICKET_ID $LABEL_ID
6. Set Tags on Entities
Tags are key-value pairs that attach to any entity type: project, ticket,
user, team, or time_entry. Use PUT to set or upsert tags — if a key
already exists, its value is updated.
Tag a project:
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tags": [
{"key": "department", "value": "engineering"},
{"key": "environment", "value": "staging"}
]
}' | jq .
[
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "environment",
"value": "staging",
"created_at": "...",
"updated_at": "..."
}
]
Retrieve all tags on an entity:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "...",
"value": "...",
"created_at": "...",
"updated_at": "..."
}
]
Delete a single tag by key:
curl -s -X DELETE "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags/environment" \
-H "Authorization: Bearer $TOKEN"
This returns 204 No Content.
Supported entity types:
project,ticket,user,team,time_entry
7. Search by Tags
Find all entities with a matching tag key-value pair using the search endpoint. Results are paginated.
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/tags/search?key=department&value=engineering" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
This returns all tagged entities across types — projects, tickets, users,
teams, and time entries. Use the entity_type and entity_id fields in each
result to look up the full entity.
8. Filter Reports by Tags
Capitalization reports support tag-based filtering via the tag query
parameter. Use comma-separated key:value pairs to filter — multiple tags
combine with AND logic.
For example, to generate a report scoped to a department:
GET /api/v1/reports/capitalization?period=2026-03&tag=department:engineering
Combine multiple tags:
GET /api/v1/reports/capitalization?period=2026-03&tag=department:engineering,environment:production
See the API Reference — Capitalization Reports for the full set of query parameters and response format.
Next: Teams, Roles & Permissions covers managing who can do what within your organization.
For Engineering Managers
Alloy is API-first, which means everything — sprint planning, reporting, permissions, workflow enforcement — is automatable. No more clicking through a UI to get a status update. This guide shows how an EM can use Alloy to run their team, track progress, and report up, all from the command line or through integrations.
1. Why Alloy for EMs
Traditional project management tools make you the bottleneck. You click through boards, chase people for updates, and manually assemble reports. Alloy flips that:
- Everything is an API call. Standup summaries, sprint reports, and budget tracking can all be scripted or wired into Slack/CI.
- Workflow enforcement is built in. Set strict mode and the system rejects invalid transitions — no more tickets jumping from Backlog to Done.
- Multi-tenant by design. One Alloy instance serves every team in your org, with role-based access keeping things clean.
- MCP integration. Use natural language via the MCP server to query project status, create sprints, and generate reports without memorizing endpoints.
2. Sprint Planning
Create a sprint, assign tickets, and set a goal. Start with a two-week iteration:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 1 — Onboarding Flow",
"goal": "Ship user onboarding end-to-end",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Onboarding Flow",
"goal": "Ship user onboarding end-to-end",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Start the sprint when the team is ready:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Onboarding Flow",
"goal": "Ship user onboarding end-to-end",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
CLI shortcut:
alloy sprint create --project PROJ --name "Sprint 1" --goal "Ship onboarding" --start 2026-04-01 --end 2026-04-15
See the Sprints & Boards guide for the full sprint lifecycle including boards, burndowns, and completion.
3. Team Visibility
See who is working on what by listing project tickets:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "...",
"assignee_id": "...",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": "..."
}
Filter by status (?status=InProgress) or assignee (?assignee_id=...) to
narrow results.
For a quick snapshot, use the MCP server’s /alloy:standup prompt or the
get_project_summary tool, which aggregates ticket counts by status and
lists active sprints in one call.
MCP shortcut: Ask your AI assistant: “Give me a summary of the PROJ project” — it calls
get_project_summarybehind the scenes.
See the Projects & Tickets guide for filtering, searching, and bulk operations.
4. Standup Automation
Instead of asking each engineer for updates, pull standup data from Alloy
directly. The MCP /alloy:standup prompt generates a summary from recent
ticket activity — transitions, comments, and time logged.
You can also build your own standup script. List tickets updated in the last 24 hours:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?status=InProgress&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq '.items | length'
Pipe the output into Slack or your team channel. Because it is just an API, you can run this on a cron job or as a CI step.
See the MCP Tools Reference for the full list of MCP prompts and tools available for automation.
5. Workflow Enforcement
Prevent tickets from skipping steps by setting your workflow to strict mode. Create a workflow with defined transitions and enforcement:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "EM Strict Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "Review"},
{"from": "Review", "to": "Done"},
{"from": "Review", "to": "InProgress"}
],
"enforcement": "strict"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "EM Strict Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "Review"},
{"from": "Review", "to": "Done"},
{"from": "Review", "to": "InProgress"}
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
With strict enforcement, any attempt to transition a ticket outside the
allowed paths returns a 422 error. No more tickets jumping from Backlog
to Done.
See the Workflows & Statuses guide for assigning workflows to projects, enforcement modes, and handling rejected transitions.
6. Reporting
Pull capitalization reports grouped by team for leadership reviews. The report aggregates approved time entries with labor rates:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&group_by=team" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"teams": []
}
When there are approved time entries with labor rates configured, each team
entry includes team_id, team_name, total_hours, total_amount_cents,
and a projects breakdown. Add include_budget=true to track spend against
budget.
For CSV exports (useful for finance reviews and spreadsheets):
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03&group_by=team" \
-H "Authorization: Bearer $TOKEN" -o report.csv
Tip: Schedule the CSV export as a weekly cron job and email it to leadership automatically.
See the Time Tracking & Finance guide for labor rates, approval workflows, and the full reporting API.
7. Permissions
Set up roles so the right people have the right access:
| Role | Who | What they can do |
|---|---|---|
| Owner | You (the EM) | Everything — manage members, billing, settings |
| Admin | Tech leads | Create projects, manage sprints, approve time |
| Member | Engineers | Create/edit tickets, log time, comment |
| Reporter | Stakeholders | Create tickets and comments, view everything |
| Viewer | Execs, auditors | Read-only access to projects, tickets, reports |
Invite a stakeholder as a Reporter:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"email\": \"stakeholder-em@example.com\",
\"role\": \"Reporter\",
\"created_by\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "stakeholder-em@example.com",
"role": "Reporter",
"expires_at": "..."
}
See the Teams, Roles & Permissions guide for the full permission matrix, team management, and project membership.
8. Getting Started — Day 1 Checklist
Here is what to do when you first set up Alloy for your team:
- Create your organization —
POST /api/v1/orgswith your team name - Create a project for each workstream —
POST /api/v1/projects - Set up a workflow — Create a strict workflow matching your process and assign it to your projects
- Invite your team — Send invites with appropriate roles (Admin for leads, Member for engineers)
- Create your first sprint — Plan a two-week iteration with a clear goal
- Add tickets — Break work into tickets with priorities and assignees
- Configure labor rates — Set hourly rates for capitalization reporting
(
POST /api/v1/users/{id}/orgs/{org_id}/labor-rates) - Automate standups — Wire up the MCP
/alloy:standupprompt or build a cron script that queries ticket activity
Each step links to its respective guide above. Start with steps 1–4 and iterate from there.
Further reading:
- Projects & Tickets — creating and managing work
- Sprints & Boards — sprint lifecycle and burndowns
- Workflows & Statuses — process enforcement
- Time Tracking & Finance — time logging and reports
- Teams, Roles & Permissions — access control
- Labels, Tags & Organization — categorization
- API Reference — complete endpoint documentation
- MCP Tools Reference — MCP automation
For Finance Teams
Alloy provides API-first capitalization reporting, budget tracking, and time-entry approval workflows designed for finance teams responsible for ASC 350-40 / IAS 38 software cost accounting. This guide shows how to review time entries, generate capitalization reports, track budgets, export CSV data, and monitor cost center spend — all without logging into a project management UI.
1. Why Alloy for Finance
Traditional project management tools treat finance as an afterthought. Reports are buried in dashboards, export formats are inconsistent, and auditors end up chasing engineers for timesheets. Alloy flips that:
- Capitalization-aware from day one. Projects carry
capitalization_type,development_phase,cost_center_id, andbudget_centsfields. Reports use these to split CapEx from OpEx automatically. - Approval workflows built in. Time entries follow a
Draft -> Submitted -> Approvedlifecycle. Only approved entries appear in reports — no more unapproved hours sneaking into the numbers. - Labor rates with effective dates. Track loaded cost rates per employee over time. Rate changes are versioned, so historical reports stay accurate.
- CSV export for ERP import. One-click export produces a file ready for SAP, Oracle, NetSuite, or any spreadsheet workflow.
- API-first. Every query in this guide can be automated, scheduled, or wired into your existing financial systems.
2. Setting Up Cost Centers and Budgets
Finance teams rely on cost centers and budgets to allocate engineering spend. When creating or updating a project, set these fields:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"PAY\",
\"name\": \"Payment Platform\",
\"description\": \"Payment processing rebuild\",
\"capitalization_type\": \"Capex\",
\"development_phase\": \"AppDevelopment\",
\"cost_center_id\": \"ENG-002\",
\"budget_cents\": 10000000,
\"budget_period\": \"Quarterly\",
\"amortization_months\": 36
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "PAY",
"name": "Payment Platform",
"description": "Payment processing rebuild",
"ticket_counter": 0,
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-002",
"budget_cents": 10000000,
"budget_period": "Quarterly",
"amortization_months": 36
}
Key fields for finance:
| Field | Purpose |
|---|---|
capitalization_type | Capex or Opex — determines accounting treatment |
development_phase | Preliminary, AppDevelopment, or PostImplementation — ASC 350-40 phase |
cost_center_id | Your internal cost center code for GL mapping |
budget_cents | Budget in cents (e.g. 10000000 = $100,000.00) |
budget_period | Monthly, Quarterly, Yearly, or Fixed |
amortization_months | Useful life for amortization scheduling |
Update finance fields on an existing project:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"budget_cents": 12000000,
"development_phase": "PostImplementation"
}' | jq .
{
"id": "...",
"org_id": "...",
"key": "PAY",
"name": "Payment Platform",
"description": "Payment processing rebuild",
"ticket_counter": 0,
"capitalization_type": "Capex",
"development_phase": "PostImplementation",
"cost_center_id": "ENG-002",
"budget_cents": 12000000,
"budget_period": "Quarterly",
"amortization_months": 36
}
See the Time Tracking & Finance guide for the full project setup flow including ticket creation.
3. Managing Labor Rates
Labor rates are the bridge between hours logged and dollar amounts in reports. Each user gets a loaded rate (salary + benefits + overhead) that takes effect from a specific date.
Set a labor rate for an employee:
curl -s -X POST "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 17500,
\"effective_date\": \"2026-01-01\"
}" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 17500,
"effective_date": "2026-01-01",
"created_at": "...",
"updated_at": "..."
}
A loaded_rate_cents of 17500 means $175.00/hour. When rates change
(promotions, annual adjustments), add a new rate with a future effective
date. The system uses the most recent rate on or before the time entry date:
curl -s -X POST "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 18500,
\"effective_date\": \"2026-07-01\"
}" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 18500,
"effective_date": "2026-07-01",
"created_at": "...",
"updated_at": "..."
}
View all historical rates for an employee:
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 18500,
"effective_date": "2026-07-01",
"created_at": "...",
"updated_at": "..."
},
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 17500,
"effective_date": "2026-01-01",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
See the Time Tracking & Finance guide for more on rate management and how rates interact with reports.
4. Reviewing and Approving Time Entries
Finance teams need visibility into what has been submitted and what is still pending. List all submitted time entries awaiting approval:
curl -s "$BASE_URL/api/v1/time-entries/submitted" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [],
"next_cursor": null,
"has_more": false
}
When there are submitted entries, each item includes id, user_id,
ticket_id, project_id, date, duration_minutes, description,
activity_type, status (Submitted), and approved_by/approved_at
(both null until approved).
Approve a submitted entry (requires Admin or Owner role):
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/approve" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 120,
"description": "Implemented payment gateway integration",
"activity_type": "Coding",
"status": "Approved",
"approved_by": "...",
"approved_at": "...",
"created_at": "...",
"updated_at": "..."
}
Only Approved time entries are included in capitalization reports. The
status lifecycle is: Draft -> Submitted -> Approved (or Rejected).
See the Time Tracking & Finance guide for the full approval workflow including submission and rejection.
5. Capitalization Reports by Project
The primary report for finance teams — aggregate approved time with labor rates, grouped by project and broken down by activity type:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": []
}
When there are approved time entries with labor rates configured, each
project entry includes project_id, project_key, project_name,
capitalization_type, development_phase, cost_center_id,
total_hours, total_amount_cents, and a breakdown array with per-activity
activity_type, hours, and amount_cents.
Each project entry includes the capitalization_type and development_phase,
which determine whether hours are CapEx or OpEx under ASC 350-40. Activity
types like Coding, Testing, and Design during AppDevelopment are
typically capitalizable. Maintenance and Training are always OpEx.
6. Reports by Team with Budget Tracking
For cross-team budget oversight, group the capitalization report by team and include budget fields:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&group_by=team&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"teams": []
}
When teams have approved time entries with labor rates configured, each
team entry includes team_id, team_name, total_hours,
total_amount_cents, and a projects breakdown. The budget fields on
each project include:
| Field | Description |
|---|---|
budget_cents | Total budget for the period |
budget_period | Monthly, Quarterly, Yearly, or Fixed |
spent_cents | Approved time cost for the period |
budget_remaining_cents | budget_cents - spent_cents |
budget_utilization_pct | Percentage of budget consumed |
Group by user instead of team for individual contributor analysis:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&group_by=user&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"users": []
}
When there are approved time entries, each user entry includes user_id,
display_name, total_hours, total_amount_cents, and a projects
array. With include_budget=true, each project also includes
budget_cents, budget_period, spent_cents, budget_remaining_cents,
and budget_utilization_pct.
See the Time Tracking & Finance guide for additional filtering by cost center, activity type, and tag.
7. Exporting CSV for ERP and Spreadsheets
Export the capitalization report as CSV for import into SAP, Oracle, NetSuite, or any spreadsheet tool:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03" \
-H "Authorization: Bearer $TOKEN" -o capitalization-2026-03.csv
The CSV includes these columns:
Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization
Filter exports by cost center for department-level reporting:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03&cost_center_id=ENG-002" \
-H "Authorization: Bearer $TOKEN" -o eng-002-report.csv
Export with team grouping:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03&group_by=team" \
-H "Authorization: Bearer $TOKEN" -o team-report.csv
Automation tip: Schedule CSV exports as a weekly or monthly cron job. The API is stateless, so the same curl command produces a fresh report each time it runs.
8. Filtering by Cost Center and Activity Type
Narrow reports to a specific cost center or activity type for detailed analysis:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&cost_center_id=ENG-002&activity_type=Coding" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": []
}
When there are approved entries matching the filters, each project includes the full breakdown by activity type with hours and amounts.
Use tags for even finer slicing. Tag time entries with metadata like
billable:true or client:acme-corp, then filter reports by tag:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03&tag=billable:true" \
-H "Authorization: Bearer $TOKEN" -o billable-report.csv
See the Time Tracking & Finance guide for how to tag time entries and search by tag.
9. Understanding Activity Types for Capitalization
Activity types determine whether hours are capitalizable. Alloy defines 13 types used in reports:
| Activity Type | CapEx Eligible | Notes |
|---|---|---|
Coding | Yes | Core development work |
Testing | Yes | Writing and running tests |
CodeReview | Yes | Pull request reviews |
Design | Yes | UI/UX design work |
Architecture | Yes | System design decisions |
Documentation | Yes | Technical documentation |
Deployment | Yes | CI/CD and release work |
BugFixing | Yes (Development) | Only during AppDevelopment phase |
PM | Depends | Capitalizable during AppDevelopment |
Requirements | Planning only | Capitalizable during Preliminary phase |
Meetings | Depends | Context-dependent |
Training | No | Always OpEx |
Maintenance | No | Always OpEx |
The report automatically classifies each entry based on the activity type
and the project’s development_phase. During audits, filter by activity
type to verify correct classification.
10. Getting Started — Finance Team Checklist
Here is what to do when setting up Alloy for finance oversight:
- Request Viewer or Admin access — Viewer for read-only reports, Admin if you need to approve time entries or manage labor rates
- Verify cost centers — Ensure each project has the correct
cost_center_idmatching your GL chart of accounts - Set capitalization fields — Confirm
capitalization_typeanddevelopment_phaseon every project - Configure labor rates — Set loaded rates for all team members with
correct effective dates (
POST /api/v1/labor-rates) - Set budgets — Add
budget_centsandbudget_periodto projects for budget tracking in reports - Review submitted time — Check
GET /api/v1/time-entries/submittedregularly and approve or reject entries - Generate your first report — Run the capitalization report for the
current period with
include_budget=true - Export CSV — Test the CSV export and verify it maps to your ERP import format
- Schedule automation — Set up cron jobs for weekly CSV exports and budget utilization alerts
- Tag for dimensions — Use tags like
department:*andbillable:*for multi-dimensional reporting
Further reading:
- Time Tracking & Finance — full time entry lifecycle, labor rates, and reporting API
- Teams, Roles & Permissions — access control and role requirements
- Labels, Tags & Organization — tagging for reporting dimensions
- For Engineering Managers — the EM perspective on reporting and sprint tracking
- API Reference — complete endpoint documentation
- MCP Tools Reference — MCP automation for reports
For DevOps & Platform Teams
Alloy is a single binary with embedded migrations, automatic TLS, and a full REST API. No UI servers, no SPA builds, no external migration tools. This guide shows how to deploy Alloy, manage API keys for CI/CD pipelines, set up webhooks for event-driven automation, and monitor service health — all from the command line.
1. Why Alloy for DevOps
Traditional project management tools are SaaS black boxes. You cannot self-host them, you cannot script them reliably, and you cannot integrate them into your infrastructure without fragile browser automation. Alloy is different:
- Single binary, zero runtime dependencies. Download, set env vars, run. No JVM, no Node, no container orchestrator required.
- Embedded migrations. The binary carries its own schema. Set
ALLOY_AUTO_MIGRATE=trueand it migrates on startup — no external migration runner needed. - Automatic TLS. Pass
--tls-domainand Alloy provisions a Let’s Encrypt certificate via ACME. No reverse proxy required. - Dual database backends. SQLite for dev/staging (single file, zero config), PostgreSQL for production (multi-tenant, RLS).
- API-first. Every operation is a REST call. CI scripts, Terraform providers, and monitoring hooks all work natively.
2. Deploying Alloy
Build and run the binary with minimal configuration:
cargo build --release
ALLOY_DATABASE_URL=sqlite://alloy.db \
ALLOY_AUTO_MIGRATE=true \
PORT=3000 \
./target/release/alloy serve
For production with PostgreSQL and TLS:
ALLOY_DATABASE_URL=postgres://alloy:secret@db:5432/alloy \
ALLOY_AUTO_MIGRATE=true \
ALLOY_JWT_PRIVATE_KEY_FILE=/etc/alloy/jwt.pem \
ALLOY_JWT_PUBLIC_KEY_FILE=/etc/alloy/jwt.pub \
ALLOY_TLS_DOMAIN=alloy.example.com \
ALLOY_TLS_CONTACT=ops@example.com \
PORT=443 \
./target/release/alloy serve
Key environment variables for operators:
| Variable | Default | Purpose |
|---|---|---|
ALLOY_DATABASE_URL | sqlite://alloy.db | sqlite://path or postgres://... |
ALLOY_AUTO_MIGRATE | true | Run embedded migrations on startup |
PORT | 3000 | HTTP listen port |
ALLOY_REGISTRATION | open | open or invite |
ALLOY_CORS_ORIGINS | — | Comma-separated allowed origins |
ALLOY_RATE_LIMIT_GLOBAL | — | Requests/min per IP (public endpoints) |
ALLOY_RATE_LIMIT_AUTH | — | Requests/min (authenticated endpoints) |
ALLOY_HTTPS | — | true to set Secure cookie flag |
See the Deployment Guide for the full environment variable reference including S3, Slack, GitHub, and SCIM configuration.
3. Health Checks
Use the unauthenticated /health endpoint for liveness probes,
load balancer checks, and uptime monitors:
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "...",
"database": "...",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
The database field reports the active backend (sqlite or postgresql).
The db_healthy field confirms the database can respond to queries.
The migration_version field shows the latest applied migration number.
The uptime_seconds field shows how long the server has been running.
Wire this into your monitoring:
- Kubernetes:
livenessProbe.httpGet.path: /health - AWS ALB: Target group health check path
/health - Uptime monitors: Alert when
statusis notok
4. API Key Management
API keys let CI/CD pipelines and scripts authenticate without user
credentials. Keys are prefixed alloy_live_ and the raw key is only
shown once at creation time.
Create a key for your CI pipeline:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "CI Pipeline Key",
"scopes": ["read", "write"]
}' | jq .
{
"id": "...",
"name": "CI Pipeline Key",
"key": "...",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
Store the key value in your secrets manager immediately — it cannot be
retrieved again. The key authenticates via the same Authorization: Bearer
header as JWTs.
List all keys to audit active credentials:
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"name": "CI Pipeline Key",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"last_used_at": null,
"expires_at": null
}
]
The list response shows key_prefix (first 15 characters) but never the
full key. Use last_used_at to identify stale keys for rotation.
Available scopes:
| Scope | Access |
|---|---|
read | GET endpoints only |
write | GET + POST/PUT/PATCH/DELETE |
admin | Full access (equivalent to read + write) |
Create a read-only key for monitoring dashboards:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Monitoring Read-Only",
"scopes": ["read"]
}' | jq .
{
"id": "...",
"name": "Monitoring Read-Only",
"key": "...",
"key_prefix": "...",
"scopes": ["read"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
Tip: Use
project_idsto restrict a key to specific projects. Pass"project_ids": ["<uuid>"]when creating the key.
5. Revoking API Keys
Revoke a compromised or unused key by deleting it:
curl -s -X DELETE "$BASE_URL/api/v1/api-keys/$API_KEY_ID" \
-H "Authorization: Bearer $TOKEN" -w "%{http_code}" -o /dev/null
Returns 204 No Content on success. The key is immediately invalidated —
any pipeline using it will start receiving 401 Unauthorized.
6. Webhooks for Event-Driven Automation
Webhooks push real-time events to your systems when things happen in Alloy. Use them to trigger CI builds on ticket transitions, post to Slack, or update external dashboards.
Create a webhook to receive ticket events:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"url": "https://hooks.example.com/alloy",
"event_types": ["ticket.created", "ticket.updated"]
}' | jq .
{
"id": "...",
"org_id": "...",
"url": "https://hooks.example.com/alloy",
"secret": "...",
"event_types": ["ticket.created", "ticket.updated"],
"active": true,
"created_at": "...",
"updated_at": "..."
}
Save the secret — it is only shown at creation time. Use it to verify
webhook signatures via the X-Alloy-Signature header (HMAC-SHA256 of the
request body).
Available event types:
| Event | Fires when |
|---|---|
ticket.created | A new ticket is created |
ticket.updated | A ticket’s fields change |
ticket.status_changed | A ticket transitions status |
comment.created | A comment is added |
sprint.started | A sprint is started |
sprint.completed | A sprint is completed |
List your webhooks:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"url": "https://hooks.example.com/alloy",
"event_types": ["ticket.created", "ticket.updated"],
"active": true,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null
}
7. Webhook Delivery Monitoring
Monitor webhook delivery success and debug failures by listing delivery attempts:
curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/deliveries" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [],
"next_cursor": null
}
When deliveries exist, each entry includes status (pending, success,
failed), response_status, attempt count, and next_retry_at for
failed deliveries. Alloy retries with exponential backoff: 1 minute,
5 minutes, 25 minutes, ~2 hours, ~10 hours.
Verify webhook signatures in your receiver:
import hmac, hashlib
def verify_signature(secret, body, signature):
expected = hmac.new(
secret.encode(), body.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Each delivery includes these headers:
| Header | Content |
|---|---|
X-Alloy-Signature | HMAC-SHA256 hex digest |
X-Alloy-Event | Event type string |
X-Alloy-Delivery | Unique delivery UUID |
8. GitHub Integration
Connect GitHub to Alloy so pull requests automatically transition tickets.
Set ALLOY_GITHUB_WEBHOOK_SECRET on the Alloy server, then configure a
GitHub webhook pointing to:
POST /api/v1/integrations/github/webhook
When a PR branch, title, or body contains a ticket reference like PROJ-42:
- PR opened → ticket transitions to
InReview - PR merged → ticket transitions to
Done - PR closed (not merged) → ticket transitions to
InProgress
No manual status updates needed. Engineers work in Git, Alloy stays in sync.
9. CI/CD Pipeline Recipes
Use API keys in your CI pipelines to automate project management alongside code changes.
Transition a ticket when a deploy succeeds:
TICKET_REF="PROJ-42"
curl -s -X POST "$ALLOY_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_API_KEY" \
-d '{"to_status": "Done"}'
Post a deploy comment on a ticket:
curl -s -X POST "$ALLOY_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_API_KEY" \
-d "{\"body\": \"Deployed to production via CI at $(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
Generate a weekly capitalization report for finance:
curl -s "$ALLOY_URL/api/v1/reports/capitalization?period=2026-03&group_by=project" \
-H "Authorization: Bearer $ALLOY_API_KEY" -o cap-report.json
These recipes use plain code blocks because they contain shell variables not set in the verification environment.
10. Getting Started — Day 1 Checklist
Here is what to do when setting up Alloy for your platform:
- Deploy the binary — Build with
cargo build --release, setALLOY_DATABASE_URLandPORT, runalloy serve - Verify health —
curl /healthreturns{"status": "ok"} - Generate JWT keys — Create Ed25519 keypair, set
ALLOY_JWT_PRIVATE_KEY_FILEandALLOY_JWT_PUBLIC_KEY_FILE - Enable TLS — Set
--tls-domainand--tls-contactfor automatic Let’s Encrypt provisioning - Create CI API keys —
POST /api/v1/api-keyswith appropriate scopes, store in your secrets manager - Set up webhooks —
POST /api/v1/orgs/{org_id}/webhooksfor ticket events your pipelines care about - Configure GitHub integration — Set
ALLOY_GITHUB_WEBHOOK_SECRETand create a GitHub webhook pointing to Alloy - Add monitoring — Wire
/healthinto your uptime monitor and alerting system - Set rate limits — Configure
ALLOY_RATE_LIMIT_GLOBALandALLOY_RATE_LIMIT_AUTHfor your traffic patterns - Back up your database — For SQLite: copy the
.dbfile. For PostgreSQL: usepg_dump
Each step links to its respective section above. Start with steps 1–3 and iterate from there.
Further reading:
- Deployment Guide — full environment variable reference
- API Reference — complete endpoint documentation
- Teams, Roles & Permissions — access control
- API Automation Tutorial — scripting patterns
- MCP Tools Reference — MCP automation
For Individual Developers
Alloy works just as well for a solo developer as it does for a large team. You do not need an org chart, a sprint ceremony, or a project manager. This guide shows the simplest possible workflow: create a project, track your work, and stay organized — all from the terminal.
1. Why Alloy for Solo Developers
Most project management tools are designed for teams. They force you into roles, permissions, and processes you do not need when you are working alone. Alloy is different:
- Zero-config start. SQLite mode, no database server, no Docker. One binary, one command.
- API-first. Script your workflow with
curl, automate with cron, or integrate with CI — no UI to click through. - TUI for quick work. The built-in terminal UI (
alloy-tui) gives you a vim-style interface for browsing tickets and sprints without leaving the terminal. - MCP for natural language. Ask your AI assistant to create tickets, check status, or log time — the MCP server translates intent into API calls.
- Scales when you need it. Start solo on SQLite. When your side project becomes a team effort, switch to PostgreSQL and invite collaborators.
2. Getting Started — 5 Minute Setup
Follow the Getting Started guide to install and run Alloy. The short version:
ALLOY_REGISTRATION=open ./target/release/alloy serve
Register, create an org, and get your API key. Then set your environment:
export BASE_URL="http://localhost:3000"
export TOKEN="alloy_live_..."
Create a project for your work:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"name\": \"side-project\",
\"key\": \"SIDE\",
\"description\": \"My weekend project\"
}" | jq .
{
"id": "...",
"org_id": "...",
"name": "side-project",
"key": "SIDE",
"description": "My weekend project",
"ticket_counter": 0
}
That is it. You have a project. Start adding tickets.
3. The Simplest Workflow
You do not need sprints, workflows, or labels to get started. The simplest workflow is: create a ticket, work on it, mark it done.
Create a ticket:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Add user authentication\",
\"status\": \"Open\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "Add user authentication",
"status": "Open",
"priority": "High",
"reporter_id": "..."
}
Update the status when you start working:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"status": "InProgress"}' | jq .
{
"id": "...",
"title": "...",
"status": "InProgress"
}
Mark it done when you are finished:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"status": "Done"}' | jq .
{
"id": "...",
"title": "...",
"status": "Done"
}
Three API calls. That is the entire lifecycle. When you need more structure (enforced transitions, review stages), see Workflows & Statuses.
4. Using the TUI
For day-to-day work, the TUI is faster than curl. Launch it:
alloy-tui
The TUI uses vim-style keybindings:
| Key | Action |
|---|---|
j / k | Move up and down |
Enter | Open ticket detail |
n | New ticket |
e | Edit ticket |
/ | Search |
q | Quit |
Navigate to your project, see all tickets at a glance, create new ones, and transition statuses — all without leaving the terminal.
See the TUI Guide for the full keybinding reference and configuration options.
5. Using MCP with Your AI Assistant
The Alloy MCP server lets you manage your project through natural language. Connect it to your AI assistant (Claude, etc.) and you can say things like:
- “Create a ticket for adding dark mode support”
- “What tickets are in progress?”
- “Log 2 hours on ticket SIDE-3”
- “Give me a summary of the SIDE project”
The MCP server calls the Alloy API on your behalf. It supports all the same operations — creating tickets, transitioning statuses, logging time, running reports.
This is especially powerful for solo developers: instead of context-switching to a browser or remembering API endpoints, just describe what you want in plain English.
See the MCP Guide and MCP Tools Reference for setup and the full list of available tools.
6. Tracking Time
Even solo, tracking time helps you understand where your hours go. Log time against a ticket:
curl -s -X POST "$BASE_URL/api/v1/time-entries" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"user_id\": \"$USER_ID\",
\"ticket_id\": \"$TICKET_ID\",
\"project_id\": \"$PROJECT_ID\",
\"date\": \"2026-03-28\",
\"duration_minutes\": 90,
\"description\": \"Implemented auth middleware\",
\"activity_type\": \"Coding\"
}" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 90,
"description": "Implemented auth middleware",
"activity_type": "Coding",
"status": "Draft",
"approved_by": null,
"approved_at": null,
"created_at": "...",
"updated_at": "..."
}
Pull a capitalization report to see how you spent your month:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-03",
"projects": []
}
When there are approved time entries with labor rates configured, the report includes hours and costs grouped by project.
See Time Tracking & Finance for the full time tracking workflow including approvals and reports.
7. Staying Organized with Labels and Tags
As your project grows, use labels to categorize tickets:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "solo-bug", "color": "#e11d48"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "solo-bug",
"color": "#e11d48",
"created_at": "...",
"updated_at": "..."
}
Add labels to tickets for filtering. Use tags for freeform metadata like
frontend, backend, blocked, or tech-debt.
See Labels, Tags & Organization for the full labeling and tagging workflow.
8. Growing from Solo to Team
When your project needs collaborators, Alloy grows with you:
-
Switch to PostgreSQL — For multi-tenant isolation and concurrent access, point
ALLOY_DATABASE_URLat a PostgreSQL instance -
Invite team members — Send invites with appropriate roles:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d "{ \"email\": \"teammate@example.com\", \"role\": \"Member\", \"created_by\": \"$USER_ID\" }" | jq .{ "id": "...", "invite_code": "...", "invite_link": "...", "email": "teammate@example.com", "role": "Member", "expires_at": "..." } -
Add a workflow — Enforce your process with defined transitions
-
Start sprints — Organize work into iterations when the team is ready
-
Set up permissions — Use roles to control who can do what
Everything you built as a solo developer carries forward. No migration, no re-setup. Just add people and process as you need them.
Further reading:
- Getting Started — installation and first steps
- TUI Guide — terminal UI keybindings and configuration
- MCP Guide — natural language project management
- Projects & Tickets — creating and managing work
- Workflows & Statuses — process enforcement
- Time Tracking & Finance — time logging and reports
- Labels, Tags & Organization — categorization
- API Reference — complete endpoint documentation
For Project Owners & Managers
Alloy gives project owners and managers full control over their projects through an API-first approach. No dashboards to click through, no forms to fill out — every action is a single API call or MCP command. This guide covers the end-to-end workflow: setting up projects, managing backlogs, running sprints, tracking budgets, and reporting to stakeholders.
1. Why Alloy for Project Managers
Traditional tools bury project management behind UIs that break your flow. You switch context to update a board, chase engineers for status updates, and manually assemble sprint reports. Alloy is different:
- API-first. Every operation — creating tickets, starting sprints, pulling reports — is a single API call. Script it, schedule it, or run it from the terminal.
- MCP integration. Use natural language via the MCP server to manage your project. Ask your AI assistant to create sprints, assign tickets, or get a project summary without memorizing endpoints.
- Workflow enforcement. Define exactly which status transitions are allowed. Strict mode rejects invalid moves — no more tickets jumping from Backlog to Done.
- Budget tracking built in. Projects carry
budget_centsandcapitalization_typefields. Track spend against budget as time entries are approved. - Multi-tenant isolation. Your organization’s data is isolated at the database level. Project members see only what they should.
2. Creating and Configuring a Project
Every project starts with a name, a key (for ticket prefixes), and an organization:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"name\": \"Website Redesign\",
\"key\": \"WEB\",
\"org_id\": \"$ORG_ID\"
}" | jq .
{
"id": "...",
"org_id": "...",
"name": "Website Redesign",
"key": "WEB",
"created_at": "...",
"updated_at": "..."
}
Update a project to add budget tracking and capitalization metadata:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"budget_cents": 5000000,
"capitalization_type": "Capex",
"development_phase": "AppDevelopment"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "...",
"key": "...",
"budget_cents": 5000000,
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"created_at": "...",
"updated_at": "..."
}
MCP shortcut: Ask your AI assistant: “Create a project called Website Redesign with key WEB” — it calls
create_projectbehind the scenes.
See the Projects & Tickets guide for full project configuration options.
3. Building Your Team
Add members to your project with the right roles. Project membership controls who can see and interact with a project:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"user_id\": \"$USER_ID\"
}" | jq .
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
List who is on the project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
]
Need to bring someone new into the org first? Send an invite:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"email\": \"designer-pm@example.com\",
\"role\": \"Member\",
\"created_by\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "designer-pm@example.com",
"role": "Member",
"expires_at": "..."
}
See the Teams, Roles & Permissions guide for the full permission matrix and role descriptions.
4. Managing the Backlog
Create tickets to track work. Every ticket gets a project-scoped number automatically:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Design homepage wireframes\",
\"description\": \"Create wireframes for the new homepage layout\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "Design homepage wireframes",
"description": "Create wireframes for the new homepage layout",
"status": "...",
"priority": "High",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
Assign a ticket to a team member:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"assignee_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "...",
"assignee_id": "...",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
List the backlog filtered by priority:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?priority=High&limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "High",
"assignee_id": "...",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": "..."
}
MCP shortcut: Ask your AI assistant: “Show me all high-priority tickets in WEB” — it calls
search_ticketswith the right filters.
See the Projects & Tickets guide for search, filtering, and bulk operations.
5. Organizing with Labels and Tags
Use labels to categorize tickets by type, component, or any dimension that matters to your project:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "frontend",
"color": "#3B82F6"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "frontend",
"color": "#3B82F6",
"created_at": "...",
"updated_at": "..."
}
Apply a label to a ticket:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"org_id": "...",
"name": "...",
"color": "...",
"created_at": "...",
"updated_at": "..."
}
]
Use tags for lightweight, key-value grouping (no pre-creation needed):
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/ticket/$TICKET_ID/tags" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"tags": [{"key": "quarter", "value": "Q2"}, {"key": "blocker", "value": "launch"}]
}' | jq .
[
{
"id": "...",
"entity_type": "...",
"entity_id": "...",
"key": "...",
"value": "...",
"created_at": "..."
}
]
MCP shortcut: “Tag ticket WEB-5 as launch-blocker” — calls
set_tags.
See the Labels, Tags & Organization guide for the full labeling and tagging workflow.
6. Setting Up Workflows
Define how tickets flow through your process. As a PM, you control the allowed transitions and enforcement level:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "PM Standard Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "Review"},
{"from": "Review", "to": "Done"},
{"from": "Review", "to": "InProgress"}
],
"enforcement": "strict"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "PM Standard Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "Review"},
{"from": "Review", "to": "Done"},
{"from": "Review", "to": "InProgress"}
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
With strict enforcement, the API rejects any transition not in the allowed
list. Use "enforcement": "relaxed" if you want the workflow as guidance
rather than a hard rule.
Assign the workflow to your project by updating the project:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"workflow_id\": \"$WORKFLOW_ID\"
}" | jq .
{
"id": "...",
"org_id": "...",
"name": "Website Redesign",
"key": "WEB",
"workflow_id": "...",
"created_at": "...",
"updated_at": "..."
}
See the Workflows & Statuses guide for enforcement modes, transition rules, and handling rejected transitions.
7. Sprint Planning and Execution
Create sprints to time-box your team’s work. Set a goal so everyone knows what the sprint is about:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 1 — Homepage Launch",
"goal": "Ship the new homepage design to production",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Homepage Launch",
"goal": "Ship the new homepage design to production",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Start the sprint when the team is aligned:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Homepage Launch",
"goal": "Ship the new homepage design to production",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
Track progress with the burndown endpoint:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/burndown" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint_id": "...",
"data": "..."
}
MCP shortcut: “Start sprint 1 in WEB” — calls
start_sprint.
See the Sprints & Boards guide and the Running a Sprint playbook for the full sprint lifecycle.
8. Tracking Progress and Status
List tickets in your project to see who is working on what. Filter by status, priority, or assignee:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "...",
"reporter_id": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": "..."
}
Filter by status (?status=InProgress), priority (?priority=High), or
assignee (?assignee_id=...) to narrow results.
MCP shortcut: “Give me a summary of project WEB” — calls
get_project_summaryand returns ticket counts by status and active sprints.
9. Budget and Cost Tracking
With budget and capitalization fields set on your project, track spend as time entries are approved:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-04" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-04",
"projects": []
}
When approved time entries exist with labor rates configured, each project
entry includes project_id, project_name, total_hours,
total_amount_cents, and an activity breakdown. Compare total_amount_cents
against your project’s budget_cents to track utilization.
Add include_budget=true to include budget_cents, spent_cents,
budget_remaining_cents, and budget_utilization_pct on each project.
For CSV exports (useful for spreadsheets and ERP import):
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-04" \
-H "Authorization: Bearer $TOKEN" -o report.csv
See the Time Tracking & Finance guide for labor rates, approval workflows, and CSV export.
10. Communicating with Comments
Use comments on tickets to keep discussions in context rather than scattered across Slack and email:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"body\": \"Wireframes approved by stakeholders. Moving to development.\",
\"author_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"ticket_id": "...",
"author_id": "...",
"body": "Wireframes approved by stakeholders. Moving to development.",
"created_at": "...",
"updated_at": "..."
}
List comments on a ticket to review the discussion history:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments?limit=10" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"ticket_id": "...",
"author_id": "...",
"body": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": "..."
}
MCP shortcut: “Add a comment to WEB-3: Design approved, start coding” — calls
add_comment.
11. MCP-First Workflow
The MCP server is the fastest way to manage your project day-to-day. Here are the commands a PM uses most:
| Action | MCP Tool | What it does |
|---|---|---|
| Project overview | get_project_summary | Ticket counts by status, active sprints |
| Create a ticket | create_ticket | Add work to the backlog |
| Assign work | assign_ticket | Set the assignee on a ticket |
| Move a ticket | transition_ticket | Change status following workflow rules |
| Start a sprint | start_sprint | Kick off a planned sprint |
| Sprint burndown | get_sprint_burndown | Track completion progress |
| Search tickets | search_tickets | Find tickets by keyword, status, assignee |
| My tickets | get_my_tickets | See what is assigned to you |
| Add a comment | add_comment | Leave notes on a ticket |
| Tag a ticket | set_tags | Apply ad-hoc tags for grouping |
All MCP tools respect the same permissions as the REST API. You need the appropriate role to perform each action.
See the MCP Tools Reference for the full list of tools and their parameters.
12. Day-One Checklist
Here is a step-by-step checklist for setting up your project in Alloy:
- Create your organization —
POST /api/v1/orgs - Create a project — Set name, key, and org_id
- Configure budget — Update the project with
budget_cents,capitalization_type, anddevelopment_phase - Set up a workflow — Create a workflow matching your process, assign it to the project
- Create labels — Define categories (frontend, backend, design, bug, feature) at the org level
- Invite team members — Send invites with appropriate roles
- Add project members — Add each person to the project
- Create backlog tickets — Break work into tickets with priorities and descriptions
- Assign owners — Set an assignee on each ticket
- Create your first sprint — Plan a two-week iteration with a goal
- Start the sprint — Kick it off when the team is ready
- Set up reporting — Configure labor rates for cost tracking
Each step maps to an API call or MCP command described in this guide. For a complete walkthrough, see the Setting Up a New Team playbook.
Learn More:
- Projects & Tickets — creating and managing work
- Sprints & Boards — sprint lifecycle and burndowns
- Workflows & Statuses — process enforcement
- Time Tracking & Finance — time logging and reports
- Teams, Roles & Permissions — access control
- Labels, Tags & Organization — categorization
- Running a Sprint — step-by-step sprint playbook
- Setting Up a New Team — onboarding playbook
- API Reference — complete endpoint documentation
- MCP Tools Reference — MCP automation
Playbook — Running a Sprint (for PMs)
This playbook walks a project manager through running a complete sprint in Alloy, from backlog grooming through retrospective. Every step uses the API directly — you can script any of it, wire it into Slack, or call it through the MCP server. The guide assumes you already have an Alloy account, an organization, and a project with tickets.
For the full sprint API reference, see Sprints & Boards.
1. Prerequisites and Setup
Set shell variables so the examples are copy-pasteable:
BASE_URL="http://localhost:3000"
TOKEN="<your-access-token>"
PROJECT_ID="<your-project-uuid>"
Confirm you can reach the server:
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "0.1.0",
"database": "...",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
Check your identity:
curl -s "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"user_id": "...",
"org_id": "...",
"email": "...",
"role": "..."
}
CLI shortcut:
alloy auth me
2. Groom the Backlog
Before planning a sprint, review the backlog. List tickets in the project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?limit=20" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": "...",
"next_cursor": null,
"has_more": false
}
Update ticket priorities and descriptions as needed:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"priority": "High"}' | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "High"
}
CLI shortcut:
alloy ticket update $TICKET_ID --priority High
3. Create the Sprint
Define a two-week sprint with a clear goal:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 4 — Payments Integration",
"goal": "Ship Stripe checkout and invoice generation",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 4 — Payments Integration",
"goal": "Ship Stripe checkout and invoice generation",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Save the sprint ID:
SPRINT_ID="<id from above>"
A good sprint goal is specific and outcome-oriented. Avoid goals like “work on payments” — state what ships.
CLI shortcut:
alloy sprint create --project $PROJECT_ID --name "Sprint 4" --goal "Ship Stripe checkout" --start-date 2026-04-01 --end-date 2026-04-15
4. Assign Owners
Every ticket in the sprint should have an assignee. Update a ticket’s assignee:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"assignee_id\": \"$USER_ID\"}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"assignee_id": "..."
}
Repeat for each ticket. As a PM, make sure every ticket has an owner before the sprint starts — unowned tickets tend to drift.
CLI shortcut:
alloy ticket update $TICKET_ID --assignee $USER_ID
5. Review the Sprint Scope
Before starting, fetch the sprint to confirm it looks right:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 4 — Payments Integration",
"goal": "Ship Stripe checkout and invoice generation",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Check the sprint board to see what tickets are assigned:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/board" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint": {
"id": "...",
"name": "...",
"status": "..."
},
"columns": [
{
"status": "...",
"tickets": []
}
]
}
CLI shortcut:
alloy sprint get $SPRINT_ID
6. Start the Sprint
When the team is ready and tickets are assigned, start the sprint:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 4 — Payments Integration",
"goal": "Ship Stripe checkout and invoice generation",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
Only one sprint per project should be active at a time. Starting a sprint signals to the team that work begins now.
CLI shortcut:
alloy sprint start $SPRINT_ID
7. Run Daily Standups
Check the sprint board each morning to see where tickets are:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/board" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint": {
"id": "...",
"name": "...",
"status": "..."
},
"columns": [
{
"status": "...",
"tickets": []
}
]
}
Move tickets between columns by transitioning their status (see Sprints & Boards for transition details). If tickets are stuck, add a comment to flag the issue:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"body\": \"Blocked on API credentials from vendor. ETA?\", \"author_id\": \"$USER_ID\"}" | jq .
{
"id": "...",
"ticket_id": "...",
"author_id": "...",
"body": "Blocked on API credentials from vendor. ETA?",
"created_at": "..."
}
CLI shortcut:
alloy comment add $TICKET_ID --body "Blocked on API credentials"
8. Track Progress with Burndown
Check the burndown daily to spot trends early:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/burndown" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"sprint_id": "...",
"data": [
{
"date": "...",
"total_tickets": 0,
"completed_tickets": 0,
"remaining_tickets": 0
}
]
}
What to look for as a PM:
| Pattern | Signal | Action |
|---|---|---|
| Flat remaining line | No tickets finishing | Check blockers |
| Rising remaining | Scope creep | Cut scope or extend |
| Steep early drop | Team crushing it | Plan for next sprint |
| Cliff at end | Last-minute rush | Improve estimation |
A healthy burndown trends downward steadily. If it flatlines for more than two days mid-sprint, intervene.
9. Mid-Sprint Adjustments
Sometimes scope changes. Update the sprint goal if the direction shifts:
curl -s -X PATCH "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"goal": "Ship Stripe checkout (invoicing moved to Sprint 5)"}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 4 — Payments Integration",
"goal": "Ship Stripe checkout (invoicing moved to Sprint 5)",
"start_date": "...",
"end_date": "...",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
Document why you cut scope — your future self will thank you during retro.
CLI shortcut:
alloy sprint update $SPRINT_ID --goal "Updated goal"
10. Close the Sprint
When the iteration ends, complete the sprint:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/complete" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 4 — Payments Integration",
"goal": "...",
"start_date": "...",
"end_date": "...",
"status": "Completed",
"created_at": "...",
"updated_at": "..."
}
Unfinished tickets stay in their current status. Move them to the next sprint or back to the backlog.
CLI shortcut:
alloy sprint complete $SPRINT_ID
11. Run a Retrospective
Use ticket activity and time data to fuel your retro. Pull comments for a ticket to review discussion threads:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"ticket_id": "...",
"author_id": "...",
"body": "...",
"created_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
Key retro questions for PMs:
- Did we hit the sprint goal?
- Which tickets carried over, and why?
- Were estimates accurate? Where did we over/underestimate?
- Were there blockers we could have anticipated?
- What should we do differently next sprint?
12. Plan the Next Sprint
Create the next sprint and assign carryover tickets. List all sprints to see what has been completed:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"name": "...",
"status": "..."
}
],
"next_cursor": null,
"has_more": false
}
Create the next sprint:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 5 — Invoicing & Notifications",
"goal": "Ship invoice generation and email notifications",
"start_date": "2026-04-16",
"end_date": "2026-04-30"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 5 — Invoicing & Notifications",
"goal": "Ship invoice generation and email notifications",
"start_date": "2026-04-16",
"end_date": "2026-04-30",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Each sprint should improve on the last. Use retro insights to adjust sprint length, team capacity, and scope.
CLI shortcut:
alloy sprint create --project $PROJECT_ID --name "Sprint 5" --goal "Ship invoicing" --start-date 2026-04-16 --end-date 2026-04-30
Playbook — Quarterly Finance Reporting (for CFOs)
This playbook walks a CFO or finance lead through the end-of-quarter reporting cycle in Alloy. It covers verifying project setup, ensuring time entries are approved, setting labor rates, generating capitalization reports, exporting CSV for ERP import, and reviewing budget utilization — all through the API. The guide assumes you already have an Alloy account, an organization, and projects with time tracking enabled.
For the full time tracking API, see Time Tracking & Finance. For finance-specific setup, see For Finance Teams.
1. Prerequisites and Setup
Set shell variables so the examples are copy-pasteable:
BASE_URL="http://localhost:3000"
TOKEN="<your-access-token>"
ORG_ID="<your-org-uuid>"
Confirm you can reach the server:
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "0.1.0",
"database": "...",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
Check your identity and role:
curl -s "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"user_id": "...",
"org_id": "...",
"email": "...",
"role": "..."
}
You need Admin or Owner role to approve time entries and manage labor rates. Viewer role is sufficient for read-only report access.
CLI shortcut:
alloy auth me
2. Audit Project Finance Fields
Before generating reports, verify every project has the correct finance metadata. List all projects in the organization:
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"key": "...",
"name": "...",
"description": "...",
"ticket_counter": "...",
"capitalization_type": "...",
"development_phase": "...",
"cost_center_id": "...",
"budget_cents": "...",
"budget_period": "..."
}
],
"next_cursor": null,
"has_more": false
}
For each project, confirm these fields are set:
| Field | What to check |
|---|---|
capitalization_type | Capex or Opex — determines accounting treatment |
development_phase | Preliminary, AppDevelopment, or PostImplementation |
cost_center_id | Matches your GL chart of accounts |
budget_cents | Correct for the quarter |
budget_period | Quarterly for quarterly reporting |
Fix any missing fields before proceeding:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-002",
"budget_cents": 10000000,
"budget_period": "Quarterly"
}' | jq .
{
"id": "...",
"org_id": "...",
"key": "...",
"name": "...",
"description": "...",
"ticket_counter": "...",
"capitalization_type": "Capex",
"development_phase": "AppDevelopment",
"cost_center_id": "ENG-002",
"budget_cents": 10000000,
"budget_period": "Quarterly"
}
CLI shortcut:
alloy project listandalloy project update
3. Verify Labor Rates
Capitalization reports multiply hours by loaded labor rates. Verify that every engineer who logged time has a current rate. Check rates for a specific user:
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": "...",
"effective_date": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
If a user is missing a rate, or the rate needs updating for the new quarter, set one:
curl -s -X POST "$BASE_URL/api/v1/labor-rates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"user_id\": \"$USER_ID\",
\"org_id\": \"$ORG_ID\",
\"loaded_rate_cents\": 17500,
\"effective_date\": \"2026-04-01\"
}" | jq .
{
"id": "...",
"user_id": "...",
"org_id": "...",
"loaded_rate_cents": 17500,
"effective_date": "2026-04-01",
"created_at": "...",
"updated_at": "..."
}
A loaded_rate_cents of 17500 means $175.00/hour. The system uses the
most recent rate on or before the time entry date, so historical reports
remain accurate when rates change.
4. Review and Approve Pending Time Entries
Only Approved time entries appear in capitalization reports. Check for any submitted entries still awaiting approval:
curl -s "$BASE_URL/api/v1/time-entries/submitted" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [],
"next_cursor": null,
"has_more": false
}
When there are pending entries, each item includes id, user_id,
ticket_id, project_id, date, duration_minutes, description,
activity_type, and status (Submitted).
Approve each entry:
curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/approve" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "...",
"duration_minutes": "...",
"description": "...",
"activity_type": "...",
"status": "Approved",
"approved_by": "...",
"approved_at": "...",
"created_at": "...",
"updated_at": "..."
}
Ensure all time entries for the quarter are in Approved status before generating reports. Unapproved entries will not be included.
Tip: Send a reminder to engineering leads a week before quarter-end to submit all outstanding time entries.
5. Generate the Capitalization Report
Run the capitalization report for the quarter. Use the first month of the
quarter as the period (the report aggregates the full quarter when
budget_period is Quarterly):
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-01" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-01",
"projects": []
}
When projects have approved time entries with labor rates configured, each project entry includes:
| Field | Description |
|---|---|
project_id | Project UUID |
project_key | Short project key (e.g. PAY) |
project_name | Human-readable name |
capitalization_type | Capex or Opex |
development_phase | ASC 350-40 phase |
cost_center_id | GL mapping code |
total_hours | Sum of approved hours |
total_amount_cents | Hours multiplied by labor rates |
breakdown | Per-activity-type detail |
6. Generate Budget Utilization Report
Add include_budget=true to see budget vs. actual spend:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-01&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-01",
"projects": []
}
When there are approved entries, each project also includes:
| Field | Description |
|---|---|
budget_cents | Total budget for the period |
spent_cents | Approved time cost for the period |
budget_remaining_cents | budget_cents - spent_cents |
budget_utilization_pct | Percentage of budget consumed |
Flag any projects over 90% utilization for CFO review.
7. Group by Cost Center
For department-level reporting, filter by cost center:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-01&cost_center_id=ENG-002" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-01",
"projects": []
}
This narrows the report to only projects assigned to cost center
ENG-002. Run this for each cost center to produce department-level
summaries for the CFO deck.
8. Group by Team
For team-level reporting, use the group_by=team parameter:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-01&group_by=team&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-01",
"teams": []
}
When teams have approved time, each entry includes team_id,
team_name, total_hours, total_amount_cents, and a projects
breakdown with per-project budget utilization.
9. Export CSV for ERP Import
Export the full capitalization report as CSV for import into SAP, Oracle, NetSuite, or your spreadsheet workflow:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01" \
-H "Authorization: Bearer $TOKEN" -o capitalization-Q1-2026.csv
The CSV includes these columns:
Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization
Export filtered by cost center for department-level files:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01&cost_center_id=ENG-002" \
-H "Authorization: Bearer $TOKEN" -o eng-002-Q1-2026.csv
Export grouped by team:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01&group_by=team" \
-H "Authorization: Bearer $TOKEN" -o team-report-Q1-2026.csv
Automation tip: Schedule CSV exports as a cron job at the start of each month. The API is stateless — the same curl command produces a fresh report each time.
10. Review Activity Type Classification
During audit prep, verify that activity types are correctly classified. The classification determines CapEx vs. OpEx treatment:
| Activity Type | CapEx Eligible | Notes |
|---|---|---|
Coding | Yes | Core development work |
Testing | Yes | Writing and running tests |
CodeReview | Yes | Pull request reviews |
Design | Yes | UI/UX design work |
Architecture | Yes | System design decisions |
Documentation | Yes | Technical documentation |
Deployment | Yes | CI/CD and release work |
BugFixing | Yes (Development) | Only during AppDevelopment phase |
PM | Depends | Capitalizable during AppDevelopment |
Requirements | Planning only | Capitalizable during Preliminary phase |
Meetings | Depends | Context-dependent |
Training | No | Always OpEx |
Maintenance | No | Always OpEx |
Filter the report by activity type to spot-check classification:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-01&activity_type=Maintenance" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"period": "2026-01",
"projects": []
}
All Maintenance hours should appear as OpEx regardless of project
phase. If capitalizable hours appear under Maintenance, flag the
entries for reclassification.
11. Quarterly Close Checklist
Run through this checklist at the end of each quarter:
- Audit project fields — Verify
capitalization_type,development_phase,cost_center_id, andbudget_centson every project (Section 2) - Verify labor rates — Confirm all engineers have current loaded rates with correct effective dates (Section 3)
- Approve all time — Clear the submitted queue; reject or return any entries that need correction (Section 4)
- Generate capitalization report — Run for the quarter period with
include_budget=true(Sections 5-6) - Review by cost center — Generate per-department reports for GL mapping (Section 7)
- Review by team — Generate team-level reports for management review (Section 8)
- Export CSV — Export full and filtered CSVs for ERP import (Section 9)
- Spot-check activity types — Filter by
MaintenanceandTrainingto verify OpEx classification (Section 10) - Archive exports — Store CSV files in your document management system with the quarter label
- Update budgets — Set
budget_centsfor the next quarter on all active projects - Adjust rates — Add new labor rates with future effective dates for any pending promotions or annual adjustments
- Sign off — Record quarter-close completion in your audit trail
12. Automating the Quarterly Cycle
Script the entire quarterly cycle for hands-off execution. Here is the reporting portion as a shell script:
QUARTER="2026-01"
OUTPUT_DIR="./reports/Q1-2026"
mkdir -p "$OUTPUT_DIR"
Export the main capitalization report:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01" \
-H "Authorization: Bearer $TOKEN" -o "$OUTPUT_DIR/capitalization.csv"
Export by cost center (repeat for each department):
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01&cost_center_id=ENG-001" \
-H "Authorization: Bearer $TOKEN" -o "$OUTPUT_DIR/eng-001.csv"
Export by team:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-01&group_by=team" \
-H "Authorization: Bearer $TOKEN" -o "$OUTPUT_DIR/by-team.csv"
MCP shortcut: Use the
get_capitalization_reportandget_time_reportMCP tools to pull reports programmatically from any MCP-compatible client.
Further reading:
- Time Tracking & Finance — full time entry lifecycle, labor rates, and reporting API
- For Finance Teams — finance team setup and day-to-day operations
- Teams, Roles & Permissions — access control and role requirements
- API Reference — complete endpoint documentation
- MCP Tools Reference — MCP automation for reports
Playbook — Setting Up a New Team (for EMs)
This playbook walks an engineering manager through setting up a brand-new team in Alloy, from creating the organization to launching the first sprint. Every step uses the API directly — you can script any of it, wire it into Slack, or call it through the MCP server.
For role and permission details, see Teams, Roles & Permissions. For sprint operations, see Sprints & Boards.
1. Prerequisites and Setup
Set shell variables so the examples are copy-pasteable:
BASE_URL="http://localhost:3000"
Register your account and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "em-setup@alloy.dev",
"password": "em-setup-pass1",
"display_name": "Team Setup EM"
}' | jq .
{
"user_id": "...",
"email": "em-setup@alloy.dev",
"display_name": "Team Setup EM",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Confirm you can reach the server:
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "0.1.0",
"database": "...",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
2. Create the Organization
Every team lives inside an organization. Create one with a unique slug:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Platform Engineering",
"slug": "platform-eng-setup"
}' | jq .
{
"id": "...",
"name": "Platform Engineering",
"slug": "platform-eng-setup"
}
Save the org ID:
ORG_ID="<id from above>"
You are automatically the Owner of any organization you create. This gives you full control over membership, workflows, and settings.
3. Verify Your Identity and Role
Confirm your auth is working and you have Owner role:
curl -s "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"user_id": "...",
"org_id": "...",
"email": "...",
"role": "..."
}
CLI shortcut:
alloy auth me
4. Define a Workflow
Before creating projects, define how tickets flow through your process. A
workflow specifies statuses and allowed transitions. Set enforcement to
strict so the system rejects invalid status changes:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Platform Team Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "InReview", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "InReview"},
{"from": "InReview", "to": "Done"},
{"from": "InReview", "to": "InProgress"}
],
"enforcement": "strict"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "Platform Team Flow",
"statuses": [
{"name": "Backlog", "category": "todo"},
{"name": "Ready", "category": "todo"},
{"name": "InProgress", "category": "in_progress"},
{"name": "InReview", "category": "in_progress"},
{"name": "Done", "category": "done"}
],
"transitions": [
{"from": "Backlog", "to": "Ready"},
{"from": "Ready", "to": "InProgress"},
{"from": "InProgress", "to": "InReview"},
{"from": "InReview", "to": "Done"},
{"from": "InReview", "to": "InProgress"}
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Save the workflow ID:
WORKFLOW_ID="<id from above>"
With strict enforcement, developers cannot skip steps — a ticket must go
through Ready before it can move to InProgress. This prevents the “everything
jumps to Done” problem.
CLI shortcut:
alloy workflow create --org $ORG_ID --name "Platform Team Flow"
5. Create the First Project
Create a project for the team’s work:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"PLAT\",
\"name\": \"Platform Services\",
\"description\": \"Core platform infrastructure and developer tools\"
}" | jq .
{
"id": "...",
"org_id": "...",
"team_id": null,
"workflow_id": null,
"key": "PLAT",
"name": "Platform Services",
"description": "Core platform infrastructure and developer tools",
"ticket_counter": 0,
"capitalization_type": null,
"development_phase": null,
"cost_center_id": null,
"amortization_months": null,
"budget_cents": null,
"budget_period": null,
"created_at": "...",
"updated_at": "..."
}
Save the project ID:
PROJECT_ID="<id from above>"
CLI shortcut:
alloy project create --org $ORG_ID --key PLAT --name "Platform Services"
6. Set a Budget
If your team has a quarterly budget, set it on the project:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"budget_cents": 50000000, "budget_period": "Quarterly"}' | jq .
{
"id": "...",
"org_id": "...",
"key": "PLAT",
"name": "Platform Services",
"description": "Core platform infrastructure and developer tools",
"ticket_counter": 0,
"budget_cents": 50000000,
"budget_period": "Quarterly",
"created_at": "...",
"updated_at": "..."
}
This sets a $500,000 quarterly budget. Finance teams can use this for capitalization and budget tracking reports — see Time Tracking & Finance for details.
7. Create Labels
Labels help categorize tickets across the organization. Set up your standard taxonomy:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "bug", "color": "#FF0000"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "bug",
"color": "#FF0000",
"created_at": "...",
"updated_at": "..."
}
Create a few more labels for common categories:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "feature", "color": "#00AA00"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "feature",
"color": "#00AA00",
"created_at": "...",
"updated_at": "..."
}
A typical team needs 5-8 labels: bug, feature, tech-debt, docs,
security, blocked. Avoid label sprawl — too many defeats the purpose.
For more on labels and tags, see Labels, Tags & Organization.
8. Invite Team Members
Send invites to your team. Each invite generates a unique link:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"email\": \"dev1-setup@alloy.dev\", \"role\": \"member\", \"created_by\": \"$USER_ID\"}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "dev1-setup@alloy.dev",
"role": "Member",
"expires_at": "..."
}
Send an admin invite for your tech lead:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"email\": \"lead-setup@alloy.dev\", \"role\": \"admin\", \"created_by\": \"$USER_ID\"}" | jq .
{
"id": "...",
"invite_code": "...",
"invite_link": "...",
"email": "lead-setup@alloy.dev",
"role": "Admin",
"expires_at": "..."
}
Share the invite link with each person. They register through the link and automatically join the org with the assigned role.
Role guide for EMs:
| Role | Who gets it |
|---|---|
| Admin | Tech leads who manage workflows and invites |
| Member | Engineers who create tickets, log time, run sprints |
| Reporter | Contractors or PMs who file tickets but shouldn’t manage projects |
| Viewer | Stakeholders who need read-only access |
9. Verify Organization Members
After people accept invites, check who has joined:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"user_id": "...",
"display_name": "...",
"email": "...",
"role": "...",
"joined_at": "..."
}
]
}
CLI shortcut:
alloy org members $ORG_ID
10. Add Members to the Project
Assign team members to the project. This matters for Reporters — they can only see projects they are members of:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"user_id\": \"$USER_ID\"}" | jq .
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
List project members to confirm:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"project_id": "...",
"user_id": "...",
"created_at": "..."
}
]
Add every team member to the project. Members and above can see all projects regardless of membership, but explicit membership keeps things organized and enables Reporter-scoped access.
11. Create Seed Tickets
Populate the backlog with initial work items so the team has something to start with:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Set up CI/CD pipeline\",
\"description\": \"Configure GitHub Actions for build, test, and deploy\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Set up CI/CD pipeline",
"description": "Configure GitHub Actions for build, test, and deploy",
"status": "Backlog",
"priority": "High",
"assignee_id": null,
"reporter_id": "...",
"sprint_id": null,
"created_at": "...",
"updated_at": "..."
}
Create a few more tickets:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Document onboarding process\",
\"description\": \"Write a getting-started guide for new team members\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 2,
"title": "Document onboarding process",
"description": "Write a getting-started guide for new team members",
"status": "Backlog",
"priority": "Medium",
"assignee_id": null,
"reporter_id": "...",
"sprint_id": null,
"created_at": "...",
"updated_at": "..."
}
Aim for 10-15 backlog items before the first sprint. Include a mix of quick wins and larger initiatives so the team can build momentum.
12. Assign Tickets
Assign owners to tickets before the sprint begins:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"assignee_id\": \"$USER_ID\"}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"assignee_id": "..."
}
Every ticket should have an owner. Unowned tickets drift — as an EM, make it a rule that nothing enters a sprint without an assignee.
CLI shortcut:
alloy ticket update $TICKET_ID --assignee $USER_ID
13. Create the First Sprint
Set up a two-week sprint with a focused goal:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 1 — Foundation",
"goal": "Set up CI/CD, dev environment, and onboarding docs",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Foundation",
"goal": "Set up CI/CD, dev environment, and onboarding docs",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned",
"created_at": "...",
"updated_at": "..."
}
Save the sprint ID:
SPRINT_ID="<id from above>"
For a new team, keep the first sprint light. The goal is to establish rhythm, not to ship the entire roadmap.
CLI shortcut:
alloy sprint create --project $PROJECT_ID --name "Sprint 1" --goal "Foundation setup"
14. Set Up Webhooks
Wire Alloy events into your team’s communication channels. Create a webhook for ticket updates:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"url": "https://hooks.slack.example.com/alloy-platform",
"event_types": ["ticket.created", "ticket.status_changed", "sprint.started", "sprint.completed"]
}' | jq .
{
"id": "...",
"org_id": "...",
"url": "...",
"secret": "...",
"event_types": [
"ticket.created",
"ticket.status_changed",
"sprint.started",
"sprint.completed"
],
"active": true,
"created_at": "...",
"updated_at": "..."
}
Store the secret securely — it is only returned on creation and is used
to verify webhook signatures via the X-Alloy-Signature header.
For more on webhooks and event-driven automation, see For DevOps & Platform Teams.
15. Create API Keys for Automation
Generate API keys for CI/CD and scripts:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Platform CI", "scopes": ["read", "write"]}' | jq .
{
"id": "...",
"name": "Platform CI",
"key": "...",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
Store the key value in your CI secrets. The full key is only shown once.
Use alloy_live_ prefixed keys for production and alloy_test_ for
testing environments.
Create a read-only key for dashboards:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Dashboard Read-Only", "scopes": ["read"]}' | jq .
{
"id": "...",
"name": "Dashboard Read-Only",
"key": "...",
"key_prefix": "...",
"scopes": ["read"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
16. Verify Permissions
Before handing off to the team, verify that permissions are working correctly. List organization members to confirm roles:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"user_id": "...",
"display_name": "...",
"email": "...",
"role": "...",
"joined_at": "..."
}
]
}
Verify your team can list the project:
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"key": "PLAT",
"name": "Platform Services",
"description": "Core platform infrastructure and developer tools",
"ticket_counter": "...",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
For a complete permission testing guide, see Teams, Roles & Permissions.
17. Start the Sprint
When the team is ready, start the sprint:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"project_id": "...",
"name": "Sprint 1 — Foundation",
"goal": "Set up CI/CD, dev environment, and onboarding docs",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Active",
"created_at": "...",
"updated_at": "..."
}
Only one sprint per project can be active at a time. Starting the sprint signals to the team that work begins now.
CLI shortcut:
alloy sprint start $SPRINT_ID
18. Day-One Checklist
Use this checklist to confirm everything is in place before handing off to your team:
| Step | Done? |
|---|---|
| Organization created with unique slug | |
| Workflow defined with strict enforcement | |
| Project created with key and description | |
| Budget set (if applicable) | |
| Labels created for standard taxonomy | |
| All team members invited with correct roles | |
| Team members added to project | |
| Backlog seeded with 10-15 tickets | |
| Tickets assigned to owners | |
| First sprint created with focused goal | |
| Webhooks configured for Slack/CI notifications | |
| API keys generated for automation | |
| Permissions verified | |
| Sprint started |
What to do next:
- Set up daily standups using the sprint board — see Running a Sprint (for PMs)
- Configure time tracking for your team — see Time Tracking & Finance
- Review the For Engineering Managers guide for ongoing operations like reporting, workflow enforcement, and standup automation
Playbook — Automating Your Workflow (for DevOps)
This playbook provides seven ready-to-use automation recipes for DevOps and platform engineers. Each recipe is a complete, copy-pasteable shell script that solves a real operational problem using the Alloy API.
For scripting fundamentals (capturing IDs, chaining requests, pagination), see the API Automation Tutorial. For deployment and API key setup, see For DevOps & Platform Teams.
Prerequisites
All recipes assume these environment variables are set:
BASE_URL="http://localhost:3000"
Register a user and capture credentials:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "devops-auto@alloy.dev",
"password": "devops-auto-pass1",
"display_name": "DevOps Automation"
}' | jq .
{
"user_id": "...",
"email": "devops-auto@alloy.dev",
"display_name": "DevOps Automation",
"access_token": "..."
}
Save the token:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization for automation:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "DevOps Automation Org",
"slug": "devops-auto-org"
}' | jq .
{
"id": "...",
"name": "DevOps Automation Org",
"slug": "devops-auto-org"
}
Create a project:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"INFRA\",
\"name\": \"Infra Platform\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "INFRA",
"name": "Infra Platform",
"ticket_counter": 0
}
Recipe 1: Health Check Monitor
Monitor Alloy availability from a cron job or CI step. The /health
endpoint is unauthenticated and returns the service status and database
backend.
curl -s "$BASE_URL/health" | jq .
{
"status": "ok",
"service": "alloy",
"version": "0.1.0",
"database": "...",
"db_healthy": true,
"migration_version": "...",
"uptime_seconds": "..."
}
Full monitoring script (use plain code blocks for scripts with logic):
#!/bin/bash
# health-monitor.sh — Exit 1 if Alloy is unhealthy
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
STATUS=$(curl -sf "$ALLOY_URL/health" | jq -r '.status')
if [ "$STATUS" != "ok" ]; then
echo "ALERT: Alloy health check failed — status=$STATUS"
exit 1
fi
echo "OK: Alloy healthy"
Wire this into cron (*/5 * * * *), Kubernetes liveness probes, or your
uptime monitor.
Recipe 2: Automated Ticket Creation from CI
Create a ticket automatically when a CI pipeline detects an issue — for example, a failed deployment or a flaky test suite.
Create a ticket in the project:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"title": "CI: Deploy failed on staging",
"description": "Automated ticket from CI pipeline. Build #1234 failed during database migration step.",
"priority": "High",
"reporter_id": "'$USER_ID'"
}' | jq .
{
"id": "...",
"project_id": "...",
"title": "CI: Deploy failed on staging",
"description": "Automated ticket from CI pipeline. Build #1234 failed during database migration step.",
"priority": "High",
"status": "..."
}
Add a comment with build details:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"body": "Build log: deployment failed at migration step V42. Rollback initiated.",
"author_id": "'$USER_ID'"
}' | jq .
{
"id": "...",
"ticket_id": "...",
"body": "Build log: deployment failed at migration step V42. Rollback initiated.",
"author_id": "..."
}
Full CI script:
#!/bin/bash
# create-incident-ticket.sh — Create a ticket when deploy fails
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN}"
ALLOY_PROJECT_ID="${ALLOY_PROJECT_ID}"
ALLOY_USER_ID="${ALLOY_USER_ID}"
BUILD_NUMBER="${BUILD_NUMBER:-unknown}"
FAILURE_REASON="${FAILURE_REASON:-unspecified}"
TICKET_ID=$(curl -sf -X POST "$ALLOY_URL/api/v1/projects/$ALLOY_PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d "{
\"title\": \"CI: Build #$BUILD_NUMBER failed\",
\"description\": \"Automated incident ticket from CI.\",
\"priority\": \"High\",
\"reporter_id\": \"$ALLOY_USER_ID\"
}" | jq -r '.id')
curl -sf -X POST "$ALLOY_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d "{
\"body\": \"Failure reason: $FAILURE_REASON\",
\"author_id\": \"$ALLOY_USER_ID\"
}" > /dev/null
echo "Created incident ticket: $TICKET_ID"
Recipe 3: API Key Rotation
Rotate API keys periodically. Create a new key, update your secrets manager, then revoke the old one.
Create a new API key:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Rotated CI Key",
"scopes": ["read", "write"]
}' | jq .
{
"id": "...",
"name": "Rotated CI Key",
"key": "...",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
List existing keys to find old ones:
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"name": "Rotated CI Key",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"last_used_at": null,
"expires_at": null
}
]
Revoke the old key by deleting it (returns 204 No Content):
curl -s -X DELETE "$BASE_URL/api/v1/api-keys/$API_KEY_ID" \
-H "Authorization: Bearer $TOKEN" -w "%{http_code}" -o /dev/null
Full rotation script:
#!/bin/bash
# rotate-api-key.sh — Create new key, output it, revoke old key
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN}"
OLD_KEY_ID="${1:?Usage: rotate-api-key.sh <old-key-id>}"
# Create new key
NEW_KEY_JSON=$(curl -sf -X POST "$ALLOY_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d '{"name": "Rotated CI Key", "scopes": ["read", "write"]}')
NEW_KEY=$(echo "$NEW_KEY_JSON" | jq -r '.key')
echo "New key created: $(echo "$NEW_KEY_JSON" | jq -r '.key_prefix')..."
# Store the new key in your secrets manager here
# e.g., vault kv put secret/alloy-api-key value="$NEW_KEY"
# Revoke old key
curl -sf -X DELETE "$ALLOY_URL/api/v1/api-keys/$OLD_KEY_ID" \
-H "Authorization: Bearer $ALLOY_TOKEN"
echo "Old key $OLD_KEY_ID revoked"
Recipe 4: Webhook Setup for Event-Driven Pipelines
Set up webhooks to trigger external systems when tickets change. Use this to drive CI/CD pipelines, Slack notifications, or audit logs.
Create a webhook for ticket events:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"url": "https://hooks.example.com/alloy-auto",
"event_types": ["ticket.created", "ticket.updated", "ticket.status_changed"]
}' | jq .
{
"id": "...",
"org_id": "...",
"url": "https://hooks.example.com/alloy-auto",
"secret": "...",
"event_types": ["ticket.created", "ticket.updated", "ticket.status_changed"],
"active": true,
"created_at": "...",
"updated_at": "..."
}
Save the secret — it is only shown at creation time. Use it to verify
webhook signatures via the X-Alloy-Signature header (HMAC-SHA256).
List webhooks to confirm:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"url": "https://hooks.example.com/alloy-auto",
"event_types": ["ticket.created", "ticket.updated", "ticket.status_changed"],
"active": true,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null
}
Check delivery status:
curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/deliveries" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [],
"next_cursor": null
}
Recipe 5: Sprint Automation
Automate sprint lifecycle — create a sprint, add tickets, start it, and close it when the iteration is done.
Create a sprint:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Auto Sprint 1",
"start_date": "2026-04-01",
"end_date": "2026-04-15"
}' | jq .
{
"id": "...",
"project_id": "...",
"name": "Auto Sprint 1",
"start_date": "2026-04-01",
"end_date": "2026-04-15",
"status": "Planned"
}
List tickets in the project to find one for the sprint:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"title": "...",
"status": "..."
}
],
"next_cursor": null,
"has_more": false
}
Start the sprint:
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"name": "Auto Sprint 1",
"status": "Active"
}
Full sprint lifecycle script:
#!/bin/bash
# sprint-lifecycle.sh — Create, populate, and start a sprint
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN}"
PROJECT_ID="${ALLOY_PROJECT_ID}"
START_DATE=$(date -u +%Y-%m-%d)
END_DATE=$(date -u -v+14d +%Y-%m-%d 2>/dev/null || date -u -d "+14 days" +%Y-%m-%d)
SPRINT_ID=$(curl -sf -X POST "$ALLOY_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d "{\"name\": \"Sprint $(date +%V)\", \"start_date\": \"$START_DATE\", \"end_date\": \"$END_DATE\"}" \
| jq -r '.id')
echo "Created sprint: $SPRINT_ID"
# Assign unassigned tickets to the sprint
TICKET_IDS=$(curl -sf "$ALLOY_URL/api/v1/projects/$PROJECT_ID/tickets?limit=50" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
| jq -r '.items[] | select(.sprint_id == null) | .id')
for TID in $TICKET_IDS; do
curl -sf -X PATCH "$ALLOY_URL/api/v1/tickets/$TID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d "{\"sprint_id\": \"$SPRINT_ID\"}" > /dev/null
echo " Assigned ticket $TID"
done
# Start the sprint
curl -sf -X POST "$ALLOY_URL/api/v1/sprints/$SPRINT_ID/start" \
-H "Authorization: Bearer $ALLOY_TOKEN" > /dev/null
echo "Sprint started"
Recipe 6: Bulk Ticket Updates
Update multiple tickets at once — for example, re-prioritize all open tickets or move them to a new status when plans change.
List tickets in the project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?limit=20" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"project_id": "...",
"ticket_number": "...",
"title": "...",
"status": "...",
"priority": "..."
}
],
"next_cursor": null,
"has_more": false
}
Update a ticket’s priority:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"priority": "Urgent"
}' | jq .
{
"id": "...",
"title": "...",
"status": "...",
"priority": "Urgent"
}
Full bulk update script:
#!/bin/bash
# bulk-reprioritize.sh — Set all open tickets to a given priority
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN}"
PROJECT_ID="${ALLOY_PROJECT_ID}"
NEW_PRIORITY="${1:-Medium}"
TICKET_IDS=$(curl -sf "$ALLOY_URL/api/v1/projects/$PROJECT_ID/tickets?limit=100" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
| jq -r '.items[] | select(.status != "Done") | .id')
COUNT=0
for TID in $TICKET_IDS; do
curl -sf -X PATCH "$ALLOY_URL/api/v1/tickets/$TID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-d "{\"priority\": \"$NEW_PRIORITY\"}" > /dev/null
COUNT=$((COUNT + 1))
done
echo "Updated $COUNT tickets to priority=$NEW_PRIORITY"
Recipe 7: Scheduled Reporting
Generate reports on a schedule for stakeholders — capitalization data, sprint burndowns, or time tracking summaries.
Create a label for tracking automation:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "automated-report", "color": "#6366f1"}' | jq .
{
"id": "...",
"name": "automated-report",
"color": "#6366f1"
}
List projects to get current status:
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"name": "...",
"org_id": "..."
}
],
"next_cursor": null,
"has_more": false
}
Full reporting script:
#!/bin/bash
# weekly-report.sh — Generate a weekly project status report
set -euo pipefail
ALLOY_URL="${ALLOY_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN}"
PROJECT_ID="${ALLOY_PROJECT_ID}"
REPORT_FILE="alloy-report-$(date +%Y%m%d).json"
echo '{"generated_at": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'",' > "$REPORT_FILE"
# Project info
echo '"project":' >> "$REPORT_FILE"
curl -sf "$ALLOY_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $ALLOY_TOKEN" >> "$REPORT_FILE"
echo ',' >> "$REPORT_FILE"
# Open tickets
echo '"tickets":' >> "$REPORT_FILE"
curl -sf "$ALLOY_URL/api/v1/projects/$PROJECT_ID/tickets?limit=100" \
-H "Authorization: Bearer $ALLOY_TOKEN" >> "$REPORT_FILE"
echo ',' >> "$REPORT_FILE"
# Active sprints
echo '"sprints":' >> "$REPORT_FILE"
curl -sf "$ALLOY_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Authorization: Bearer $ALLOY_TOKEN" >> "$REPORT_FILE"
echo '}' >> "$REPORT_FILE"
echo "Report saved to $REPORT_FILE"
echo "Tickets: $(jq '.tickets.items | length' "$REPORT_FILE")"
Next Steps
- API Automation Tutorial — scripting patterns, pagination, and error handling
- For DevOps & Platform Teams — deployment, API keys, webhooks, and GitHub integration
- API Reference — complete endpoint documentation
- MCP Tools Reference — automate via MCP instead of curl
End-to-End Walkthrough
This tutorial walks you through every major feature of Alloy using nothing but
curl and jq. By the end you will have created an organization, a project,
labels, a sprint, tickets, comments, and time entries — the full lifecycle of a
project management workflow.
1. Prerequisites
You need:
- A running Alloy server (see Getting Started)
curlandjqinstalled- A terminal with shell variable support
Start the server with open registration:
ALLOY_REGISTRATION=open ./target/release/alloy serve
Set the base URL in your terminal:
BASE_URL="http://localhost:3000"
2. Register and Authenticate
Create a user account and capture the access token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "walkthrough@alloy.dev",
"password": "walkthrough1",
"display_name": "Walkthrough User"
}' | jq .
{
"user_id": "...",
"email": "walkthrough@alloy.dev",
"display_name": "Walkthrough User",
"access_token": "..."
}
Save the token and user ID for subsequent requests:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
CLI shortcut:
alloy auth login --email walkthrough@alloy.dev --password walkthrough1
3. Create an Organization
Every project lives inside an organization. Create one now:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Acme Corp",
"slug": "acme-wt"
}' | jq .
{
"id": "...",
"name": "Acme Corp",
"slug": "acme-wt"
}
Save the org ID:
ORG_ID="<id from above>"
Verify it by listing your organizations:
curl -s "$BASE_URL/api/v1/orgs" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"name": "...",
"slug": "..."
}
]
}
CLI shortcut:
alloy org create --name "Acme Corp" --slug acme-wt
4. Create a Project
Projects group tickets, sprints, and labels under a short key used in ticket
references (e.g. ALLOY-1):
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"ALLOY\",
\"name\": \"Alloy Project\",
\"description\": \"End-to-end walkthrough project\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "ALLOY",
"name": "Alloy Project",
"description": "End-to-end walkthrough project"
}
Save the project ID:
PROJECT_ID="<id from above>"
Read it back to confirm:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"key": "ALLOY",
"name": "Alloy Project",
"description": "End-to-end walkthrough project"
}
CLI shortcut:
alloy project create --org-id "$ORG_ID" --key ALLOY --name "Alloy Project"
5. Create Labels
Labels categorize tickets. Create four labels for your organization:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "bug",
"color": "#FF0000"
}' | jq .
{
"id": "...",
"name": "bug",
"color": "#FF0000"
}
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "feature",
"color": "#00FF00"
}' | jq .
{
"id": "...",
"name": "feature",
"color": "#00FF00"
}
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "docs",
"color": "#0000FF"
}' | jq .
{
"id": "...",
"name": "docs",
"color": "#0000FF"
}
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "urgent",
"color": "#FFA500"
}' | jq .
{
"id": "...",
"name": "urgent",
"color": "#FFA500"
}
List all labels in the org to confirm:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"name": "...",
"color": "..."
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy label create --org-id "$ORG_ID" --name bug --color "#FF0000" alloy label list --org-id "$ORG_ID"
6. Create a Sprint
Sprints are time-boxed iterations. Create one for your project:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Sprint 1",
"goal": "Complete the walkthrough",
"start_date": "2026-03-28",
"end_date": "2026-04-11"
}' | jq .
{
"id": "...",
"name": "Sprint 1",
"goal": "Complete the walkthrough",
"start_date": "2026-03-28",
"end_date": "2026-04-11"
}
Save the sprint ID:
SPRINT_ID="<id from above>"
Read it back:
curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"name": "Sprint 1",
"goal": "Complete the walkthrough"
}
CLI shortcut:
alloy sprint create --project-id "$PROJECT_ID" --name "Sprint 1" --goal "Complete the walkthrough"
7. Create Tickets
Tickets are the core work items. Create a ticket in the project:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Set up CI pipeline\",
\"description\": \"Configure GitHub Actions for build and test\",
\"status\": \"Backlog\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"title": "Set up CI pipeline",
"description": "Configure GitHub Actions for build and test",
"status": "Backlog",
"priority": "High",
"reporter_id": "..."
}
Save the ticket ID:
TICKET_ID="<id from above>"
Create a second ticket:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Fix login redirect\",
\"description\": \"Users are redirected to the wrong page after login\",
\"status\": \"InProgress\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"title": "Fix login redirect",
"description": "Users are redirected to the wrong page after login",
"status": "InProgress",
"priority": "High",
"reporter_id": "..."
}
And a third:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Write API docs\",
\"description\": \"Document all REST endpoints\",
\"status\": \"Backlog\",
\"priority\": \"Low\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"title": "Write API docs",
"description": "Document all REST endpoints",
"status": "Backlog",
"priority": "Low",
"reporter_id": "..."
}
CLI shortcut:
alloy ticket create --title "Set up CI pipeline" --priority high alloy ticket create --title "Fix login redirect" --status InProgress --priority high
8. Assign Labels to Tickets
Attach a label to a ticket using the set-labels endpoint:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"label_ids\": [\"$LABEL_ID\"]
}" | jq .
[
{
"id": "...",
"name": "...",
"color": "..."
}
]
List labels on the ticket to verify:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"name": "...",
"color": "..."
}
]
CLI shortcut:
alloy label set --ticket-id "$TICKET_ID" --label-ids "$LABEL_ID"
9. Transition Ticket Status
Move a ticket through workflow states. First check available transitions:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"current_status": "...",
"available": [
{
"name": "...",
"category": "..."
}
]
}
Transition the ticket to “InProgress”:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"to_status": "InProgress"
}' | jq .
{
"id": "...",
"title": "...",
"status": "InProgress"
}
CLI shortcut:
alloy ticket transition --id "$TICKET_ID" --status InProgress
10. Add Comments
Add discussion to a ticket:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"body\": \"Looks good, let's ship it\",
\"author_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"body": "Looks good, let's ship it",
"author_id": "...",
"ticket_id": "..."
}
List comments on the ticket:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"body": "...",
"author_id": "...",
"ticket_id": "..."
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy comment create --ticket-id "$TICKET_ID" --body "Looks good, let's ship it" alloy comment list --ticket-id "$TICKET_ID"
11. Track Time
Log time spent working on a ticket:
curl -s -X POST "$BASE_URL/api/v1/time-entries" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"user_id\": \"$USER_ID\",
\"ticket_id\": \"$TICKET_ID\",
\"project_id\": \"$PROJECT_ID\",
\"date\": \"2026-03-28\",
\"duration_minutes\": 120,
\"description\": \"Initial setup work\",
\"activity_type\": \"Coding\"
}" | jq .
{
"id": "...",
"user_id": "...",
"ticket_id": "...",
"project_id": "...",
"date": "2026-03-28",
"duration_minutes": 120,
"description": "Initial setup work",
"activity_type": "Coding"
}
List time entries for the ticket:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/time-entries" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"duration_minutes": 120,
"description": "Initial setup work",
"activity_type": "Coding"
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy time log --ticket-id "$TICKET_ID" --duration 120 --description "Initial setup work" --activity-type Coding alloy time list --ticket-id "$TICKET_ID"
12. List and Filter Tickets
List all tickets in the project:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"title": "...",
"status": "...",
"priority": "..."
}
],
"next_cursor": null,
"has_more": false
}
Filter by status:
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?status=InProgress" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"title": "...",
"status": "InProgress",
"priority": "..."
}
],
"next_cursor": null,
"has_more": false
}
CLI shortcut:
alloy ticket list --project-id "$PROJECT_ID" alloy ticket list --project-id "$PROJECT_ID" --status InProgress
13. Finance: Tags, Budgets, and Capitalization Reports
Alloy supports entity tagging, project budgets, and detailed capitalization reporting for software cost accounting.
Tag a Project
Tags are key-value pairs you can attach to projects, tickets, users, teams, or time entries:
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"tags": [
{"key": "department", "value": "engineering"},
{"key": "cost-type", "value": "capex"}
]
}' | jq .
[
{
"id": "...",
"entity_type": "project",
"entity_id": "...",
"key": "department",
"value": "engineering",
"created_at": "...",
"updated_at": "..."
}
]
Read back tags:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
-H "Authorization: Bearer $TOKEN" | jq .
Search for all entities with a given tag:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/tags/search?key=department&value=engineering" \
-H "Authorization: Bearer $TOKEN" | jq .
Set a Project Budget
Update a project with budget tracking fields:
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"budget_cents": 1000000,
"budget_period": "Monthly"
}' | jq .
{
"id": "...",
"name": "Alloy Project",
"budget_cents": 1000000,
"budget_period": "Monthly"
}
Supported budget periods: Monthly, Quarterly, Yearly, Fixed.
Run a Capitalization Report
Generate a report for a given month:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
-H "Authorization: Bearer $TOKEN" | jq .
Add budget/ROI data with include_budget=true:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&include_budget=true" \
-H "Authorization: Bearer $TOKEN" | jq .
Group by team or user:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&group_by=team" \
-H "Authorization: Bearer $TOKEN" | jq .
Filter by tag:
curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03&tag=department:engineering" \
-H "Authorization: Bearer $TOKEN" | jq .
Export as CSV:
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03" \
-H "Authorization: Bearer $TOKEN" -o report.csv
See the API Reference — Entity Tags and Capitalization Reports sections for the full list of query parameters.
14. Custom Workflows
Alloy supports custom workflows with named statuses, allowed transitions, and enforcement levels. Create a strict workflow that enforces a Kanban-style flow:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Kanban",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Coding", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Shipped", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Coding"},
{"from": "Coding", "to": "Review"},
{"from": "Review", "to": "Shipped"}
],
"enforcement": "strict"
}' | jq .
Expected response (201 Created):
{
"id": "...",
"org_id": "...",
"name": "Kanban",
"statuses": [
{"name": "Open", "category": "todo"},
{"name": "Coding", "category": "in_progress"},
{"name": "Review", "category": "in_progress"},
{"name": "Shipped", "category": "done"}
],
"transitions": [
{"from": "Open", "to": "Coding"},
{"from": "Coding", "to": "Review"},
{"from": "Review", "to": "Shipped"}
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Save the workflow ID:
WORKFLOW_ID="<id from above>"
Workflow Enforcement in Action
Create a ticket using a custom workflow status and test the transition rules. First, create a project that uses the default workflow, then update the default workflow to strict enforcement:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"WFT\",
\"name\": \"Workflow Test Project\"
}" | jq .
Expected response (201 Created):
{
"id": "...",
"org_id": "...",
"key": "WFT",
"name": "Workflow Test Project"
}
Create a ticket with the custom status “Open”:
curl -s -X POST "$BASE_URL/api/v1/projects/$WF_PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Workflow test ticket\",
\"description\": \"Testing enforcement\",
\"status\": \"Open\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
Expected response (201 Created):
{
"id": "...",
"title": "Workflow test ticket",
"status": "Open",
"priority": "Medium"
}
A valid transition (Open → Coding) succeeds:
curl -s -X POST "$BASE_URL/api/v1/tickets/$WF_TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "Coding"}' | jq .
Expected response (200 OK):
{
"id": "...",
"title": "Workflow test ticket",
"status": "Coding"
}
An invalid transition (Coding → Shipped, skipping Review) is rejected with a 422 error under strict enforcement:
curl -s -X POST "$BASE_URL/api/v1/tickets/$WF_TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "Shipped"}' | jq .
Expected response (422 Unprocessable Entity):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "transition from 'Coding' to 'Shipped' is not allowed by workflow 'Kanban'",
"details": []
}
}
Enforcement modes: none (any transition allowed), warn (invalid transitions
succeed with a warning), strict (invalid transitions rejected with 422).
15. Delete Guards
Alloy protects resources that have dependents from accidental deletion. Trying
to delete a project that still has tickets returns 409 Conflict:
curl -s -X DELETE "$BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Expected response (409 Conflict):
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete project with existing dependents",
"details": ["tickets: 3", "sprints: 1"]
}
}
The same applies to tickets with comments:
curl -s -X DELETE "$BASE_URL/api/v1/tickets/$TICKET_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
Expected response (409 Conflict):
{
"error": {
"code": "CONFLICT",
"message": "Cannot delete ticket with existing dependents",
"details": ["comments: 1"]
}
}
Cascade Dry-Run Preview
Before committing to a cascade delete, you can preview what would be removed
using cascade=true&dry_run=true. This returns a 200 response listing
dependents without deleting anything:
curl -s -X DELETE \
"$BASE_URL/api/v1/tickets/$TICKET_ID?cascade=true&dry_run=true" \
-H "Authorization: Bearer $TOKEN" | jq .
Expected response (200 OK — nothing is deleted):
{
"ticket_id": "...",
"dependents": {
"comments": 1
}
}
Preview a project cascade to see all nested dependents:
curl -s -X DELETE \
"$BASE_URL/api/v1/projects/$PROJECT_ID?cascade=true&dry_run=true" \
-H "Authorization: Bearer $TOKEN" | jq .
Expected response (200 OK — nothing is deleted):
{
"project_id": "...",
"dependents": {
"tickets": 3,
"sprints": 1,
"comments": 1
}
}
To actually perform the cascade delete (removes the resource and all its
dependents), use cascade=true without dry_run:
curl -s -X DELETE \
"$BASE_URL/api/v1/tickets/$TICKET_ID?cascade=true" \
-H "Authorization: Bearer $TOKEN"
# Returns: 204 No Content
16. Roles and Permissions
Alloy uses a role hierarchy: Owner > Admin > Member > Reporter > Viewer. Different roles have different permissions. Here we demonstrate the difference between a Reporter and a Member.
Invite a Reporter
Create an invite with the Reporter role and register a new user with it:
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"email\": \"reporter@alloy.dev\",
\"role\": \"reporter\",
\"created_by\": \"$USER_ID\"
}" | jq .
Expected response (201 Created):
{
"id": "...",
"email": "reporter@alloy.dev",
"role": "Reporter",
"invite_code": "..."
}
INVITE_CODE="<invite_code from above>"
Register the reporter using the invite code:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d "{
\"email\": \"reporter@alloy.dev\",
\"password\": \"repreprep1\",
\"display_name\": \"Reporter User\",
\"invite_code\": \"$INVITE_CODE\"
}" | jq .
Expected response (201 Created):
{
"user_id": "...",
"email": "reporter@alloy.dev",
"display_name": "Reporter User",
"access_token": "..."
}
REPORTER_TOKEN="<access_token from above>"
REPORTER_USER_ID="<user_id from above>"
Reporter Can Create Tickets
Reporters can create tickets in projects they are assigned to:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $REPORTER_TOKEN" \
-d "{
\"title\": \"Reporter ticket\",
\"description\": \"Created by reporter\",
\"status\": \"Backlog\",
\"priority\": \"Low\",
\"reporter_id\": \"$REPORTER_USER_ID\"
}" | jq .
Expected response (201 Created):
{
"id": "...",
"title": "Reporter ticket",
"description": "Created by reporter",
"status": "Backlog",
"priority": "Low",
"reporter_id": "..."
}
Reporter Cannot Update Tickets
Updating tickets requires the Member role or above. A Reporter gets 403 Forbidden:
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$REPORTER_TICKET_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $REPORTER_TOKEN" \
-d '{"title": "Updated by reporter"}' | jq .
Expected response (403 Forbidden):
{
"error": {
"code": "FORBIDDEN",
"message": "requires Member role or above"
}
}
Reporter Cannot Create Projects
Creating projects also requires the Member role or above:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $REPORTER_TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"REP1\",
\"name\": \"Reporter Project\"
}" | jq .
Expected response (403 Forbidden):
{
"error": {
"code": "FORBIDDEN",
"message": "requires Member role or above"
}
}
See the Getting Started guide for the full permission matrix showing what each role can do.
17. Next Steps
You have now used every core feature of Alloy through the API. Here is what to explore next:
- API Reference — Full endpoint documentation including workflows, webhooks, SCIM provisioning, and integrations
- CLI Reference — Complete command-line interface for scripting and automation
- TUI Guide — Interactive terminal UI for browsing and managing work visually
- Deployment — Production deployment with PostgreSQL, multi-tenancy, and SSO
To seed a full demo dataset automatically:
BASE_URL=http://localhost:3000 bash scripts/seed-demo.sh
API Automation with curl and jq
This tutorial covers patterns for scripting Alloy programmatically using curl
and jq. You will learn how to capture IDs, chain commands, perform bulk
operations, and integrate Alloy into CI/CD pipelines.
Prerequisites
- A running Alloy server (see Getting Started)
curlandjqinstalled- A terminal with shell variable support (
bashorzsh)
Set variables used throughout this guide:
BASE_URL="http://localhost:3000"
1. Capturing IDs
Most Alloy API responses return JSON objects containing id fields. Use jq to
extract and store them in shell variables for subsequent requests.
Extract a single field:
USER_ID=$(curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "auto@alloy.dev",
"password": "automation1",
"display_name": "Automation User"
}' | jq -r '.user_id')
echo "$USER_ID"
Output:
550e8400-e29b-41d4-a716-446655440000
Extract multiple fields at once:
eval "$(curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "auto2@alloy.dev",
"password": "automation1",
"display_name": "Automation User 2"
}' | jq -r '@sh "TOKEN=\(.access_token) USER_ID=\(.user_id)"')"
echo "Token: $TOKEN"
echo "User: $USER_ID"
The register response contains the fields you need:
Token: eyJ...
User: 550e8400-...
Extract an ID from a list response:
FIRST_PROJECT_ID=$(curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')
echo "$FIRST_PROJECT_ID"
Output:
990e8400-e29b-41d4-a716-446655440000
2. Chaining Commands
Build complete workflows by piping the output of one command into the next. This example performs a full flow: onboard, create a project, and create a ticket.
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# Step 1: Onboard — create the first org and admin user
ONBOARD=$(curl -s -X POST "$BASE_URL/api/v1/onboard" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@acme.dev",
"password": "changeme123",
"org_name": "Acme Corp",
"org_slug": "acme"
}')
ORG_ID=$(echo "$ONBOARD" | jq -r '.org_id')
API_KEY=$(echo "$ONBOARD" | jq -r '.api_key')
USER_ID=$(echo "$ONBOARD" | jq -r '.user_id')
echo "Org: $ORG_ID"
echo "Key: $API_KEY"
# Step 2: Login to get a bearer token
TOKEN=$(curl -s -X POST "$BASE_URL/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@acme.dev",
"password": "changeme123"
}' | jq -r '.access_token')
echo "Token: $TOKEN"
# Step 3: Create a project
PROJECT_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"AUTO\",
\"name\": \"Automation Project\"
}" | jq -r '.id')
echo "Project: $PROJECT_ID"
# Step 4: Create a ticket in the project
TICKET_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"First automated ticket\",
\"description\": \"Created by the chaining script\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq -r '.id')
echo "Ticket: $TICKET_ID"
# Step 5: Transition the ticket to InProgress
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "aa0e8400-...",
"title": "First automated ticket",
"status": "InProgress",
"priority": "High"
}
3. Bulk Operations
Create tickets from a shell loop
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
TITLES=("Set up CI pipeline" "Write unit tests" "Update README" "Add logging" "Deploy to staging")
for title in "${TITLES[@]}"; do
TICKET_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq -r '.id')
echo "Created ticket $TICKET_ID: $title"
done
{
"id": "bb0e8400-...",
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "Medium"
}
Create tickets from a text file
Given a file tickets.txt with one title per line:
Fix login timeout
Add password reset flow
Update error messages
Refactor auth middleware
Add rate limiting
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
while IFS= read -r title; do
[ -z "$title" ] && continue
RESULT=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}")
echo "$RESULT" | jq '{id: .id, title: .title, ticket_number: .ticket_number}'
done < tickets.txt
{
"id": "cc0e8400-...",
"title": "Fix login timeout",
"ticket_number": 6
}
4. Shell Helpers
Wrap common operations in reusable functions. Add these to your shell profile or source them from a script:
#!/usr/bin/env bash
# alloy-helpers.sh — source this file to use the helper functions
# Base configuration
ALLOY_BASE_URL="${ALLOY_BASE_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN:-}"
alloy_get() {
local path="$1"
shift
curl -s "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
"$@"
}
alloy_post() {
local path="$1"
local data="$2"
shift 2
curl -s -X POST "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$@"
}
alloy_patch() {
local path="$1"
local data="$2"
shift 2
curl -s -X PATCH "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$@"
}
alloy_delete() {
local path="$1"
shift
curl -s -X DELETE "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
"$@"
}
Usage examples:
source alloy-helpers.sh
export ALLOY_TOKEN="eyJ..."
export ALLOY_BASE_URL="http://localhost:3000"
# List projects
alloy_get "/api/v1/projects?org_id=$ORG_ID" | jq '.items[].name'
# Create a ticket
alloy_post "/api/v1/projects/$PROJECT_ID/tickets" "{
\"title\": \"Created with helper\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
# Update a ticket's status
alloy_patch "/api/v1/tickets/$TICKET_ID" '{"status": "Done"}' | jq .
{
"id": "dd0e8400-...",
"title": "Created with helper",
"status": "Backlog"
}
5. Error Handling
Always check HTTP status codes and parse error responses in automated scripts.
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN set from prior steps
# Use -w to capture the HTTP status code alongside the body
HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"org_id": "invalid", "key": "BAD", "name": "Test"}')
# Split response body and status code
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d')
HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -1)
if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then
echo "Success:"
echo "$HTTP_BODY" | jq .
else
echo "Error (HTTP $HTTP_STATUS):"
echo "$HTTP_BODY" | jq '.error'
exit 1
fi
Example error output:
Error (HTTP 404):
{
"code": "not_found",
"message": "Organization not found",
"details": []
}
Reusable error-checking function:
alloy_request() {
local method="$1"
local path="$2"
local data="${3:-}"
local args=(-s -w "\n%{http_code}" -X "$method"
"$BASE_URL$path"
-H "Authorization: Bearer $TOKEN")
if [ -n "$data" ]; then
args+=(-H "Content-Type: application/json" -d "$data")
fi
local response
response=$(curl "${args[@]}")
local body
body=$(echo "$response" | sed '$d')
local status
status=$(echo "$response" | tail -1)
if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then
echo "$body"
else
echo "ERROR: HTTP $status — $(echo "$body" | jq -r '.error.message // "Unknown error"')" >&2
return 1
fi
}
# Usage:
alloy_request GET "/api/v1/projects?org_id=$ORG_ID" | jq '.items | length'
alloy_request POST "/api/v1/projects/$PROJECT_ID/tickets" \
"{\"title\": \"Safe ticket\", \"reporter_id\": \"$USER_ID\"}" | jq .
{
"id": "ee0e8400-...",
"title": "Safe ticket",
"status": "Backlog"
}
6. CI/CD Integration
API key authentication
Use API keys instead of password-based login for non-interactive environments.
API keys are passed as Bearer tokens in the Authorization header:
#!/usr/bin/env bash
set -euo pipefail
# In CI, set these as secret environment variables
BASE_URL="${ALLOY_BASE_URL:?ALLOY_BASE_URL must be set}"
TOKEN="${ALLOY_API_KEY:?ALLOY_API_KEY must be set}"
PROJECT_ID="${ALLOY_PROJECT_ID:?ALLOY_PROJECT_ID must be set}"
# Create a ticket from CI
TICKET=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"CI build #${CI_BUILD_NUMBER:-local}\",
\"description\": \"Automated ticket from CI pipeline\",
\"priority\": \"Low\",
\"reporter_id\": \"$USER_ID\"
}")
BODY=$(echo "$TICKET" | sed '$d')
STATUS=$(echo "$TICKET" | tail -1)
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
echo "Ticket created: $(echo "$BODY" | jq -r '.id')"
exit 0
else
echo "Failed to create ticket (HTTP $STATUS):" >&2
echo "$BODY" | jq '.error' >&2
exit 1
fi
Creating an API key for CI
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "CI Key", "scopes": ["read", "write"]}' | jq .
{
"id": "770e8400-...",
"name": "CI Key",
"key": "alloy_live_abc123...",
"key_prefix": "alloy_live_...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-28-...",
"expires_at": null
}
Store the key value as a secret in your CI system and use it as the Bearer
token. The key works identically to a JWT access token.
Exit codes for automation
Scripts should use meaningful exit codes so CI pipelines can react:
#!/usr/bin/env bash
set -euo pipefail
# Verify the API is healthy before running tests
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/health")
if [ "$HEALTH" != "200" ]; then
echo "Alloy API is not healthy (HTTP $HEALTH)" >&2
exit 2
fi
# Run your operations...
echo "API is healthy, proceeding with automation."
exit 0
7. Examples
Create 10 tickets from a text file
Given backlog.txt:
Implement user avatars
Add dark mode support
Fix pagination on mobile
Optimize database queries
Add export to PDF
Implement webhook retries
Add team permissions
Create onboarding wizard
Fix timezone handling
Add bulk ticket import
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
COUNT=0
while IFS= read -r title; do
[ -z "$title" ] && continue
RESULT=$(curl -s -w "\n%{http_code}" -X POST \
"$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}")
BODY=$(echo "$RESULT" | sed '$d')
STATUS=$(echo "$RESULT" | tail -1)
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
COUNT=$((COUNT + 1))
echo "[$COUNT] Created: $(echo "$BODY" | jq -r '.title') ($(echo "$BODY" | jq -r '.id'))"
else
echo "FAILED: $title (HTTP $STATUS)" >&2
fi
done < backlog.txt
echo "Created $COUNT tickets."
{
"id": "ff0e8400-...",
"title": "Implement user avatars",
"status": "Backlog",
"priority": "Medium",
"ticket_number": 1
}
Move all InProgress tickets to Done
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID set from prior steps
# Fetch all InProgress tickets (paginate if needed)
CURSOR=""
MOVED=0
while true; do
QUERY="status=InProgress&limit=100"
[ -n "$CURSOR" ] && QUERY="$QUERY&cursor=$CURSOR"
RESPONSE=$(curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?$QUERY" \
-H "Authorization: Bearer $TOKEN")
TICKET_IDS=$(echo "$RESPONSE" | jq -r '.items[].id')
for TICKET_ID in $TICKET_IDS; do
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "Done"}' | jq '{id: .id, title: .title, status: .status}'
MOVED=$((MOVED + 1))
done
HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more')
if [ "$HAS_MORE" != "true" ]; then
break
fi
CURSOR=$(echo "$RESPONSE" | jq -r '.next_cursor')
done
echo "Moved $MOVED tickets to Done."
{
"id": "aa0e8400-...",
"title": "Fix login bug",
"status": "Done"
}
Export time entries as CSV with jq
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID set from prior steps
# Print CSV header
echo "id,ticket_id,date,duration_minutes,activity_type,description"
CURSOR=""
while true; do
QUERY="limit=100"
[ -n "$CURSOR" ] && QUERY="$QUERY&cursor=$CURSOR"
RESPONSE=$(curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/time-entries?$QUERY" \
-H "Authorization: Bearer $TOKEN")
# Convert each entry to a CSV row
echo "$RESPONSE" | jq -r '.items[] | [.id, .ticket_id, .date, .duration_minutes, .activity_type, (.description // "")] | @csv'
HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more')
if [ "$HAS_MORE" != "true" ]; then
break
fi
CURSOR=$(echo "$RESPONSE" | jq -r '.next_cursor')
done
Output:
id,ticket_id,date,duration_minutes,activity_type,description
"ff0e8400-...","aa0e8400-...","2026-03-28",120,"Coding","Investigated SSO bug"
"ff1e8400-...","bb0e8400-...","2026-03-28",60,"CodeReview","Reviewed auth PR"
Tip: Redirect the output to a file with
> time-entries.csvto save it, or pipe it to other tools for further processing.
Authentication Deep Dive
This tutorial covers every authentication mechanism in Alloy: password-based
registration, JWT token lifecycle (login, refresh, logout), API key management,
scope enforcement, and SSO via OIDC. Every example uses curl and jq so you
can follow along in your terminal.
1. Prerequisites
You need:
- A running Alloy server (see Getting Started)
curlandjqinstalled- A terminal with shell variable support
Start the server with open registration:
ALLOY_REGISTRATION=open ./target/release/alloy serve
Set the base URL:
BASE_URL="http://localhost:3000"
2. Registration
Create a new user account. The register endpoint returns an access token, a refresh token, and the user’s ID — everything you need to start making authenticated requests immediately.
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "authdemo@alloy.dev",
"password": "authdemo1",
"display_name": "Auth Demo User"
}' | jq .
{
"access_token": "...",
"refresh_token": "...",
"user_id": "...",
"email": "authdemo@alloy.dev",
"display_name": "Auth Demo User"
}
Save the tokens for subsequent requests:
TOKEN="<access_token from above>"
REFRESH_TOKEN="<refresh_token from above>"
USER_ID="<user_id from above>"
Registration automatically creates a personal organization for the new user. Verify by listing your organizations:
curl -s "$BASE_URL/api/v1/orgs" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"name": "...",
"slug": "...",
"created_at": "...",
"updated_at": "..."
}
]
}
Registration modes: Alloy supports three modes controlled by the
ALLOY_REGISTRATIONenvironment variable:
open— anyone can registerinvite_only— requires a valid invite codedisabled— registration is closed entirely
3. JWT Authentication
Alloy uses short-lived JWT access tokens (1 hour) paired with long-lived refresh tokens (30 days). This section walks through the complete login, refresh, and logout cycle.
Login
Exchange email and password for tokens:
curl -s -X POST "$BASE_URL/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "authdemo@alloy.dev",
"password": "authdemo1"
}' | jq .
{
"access_token": "...",
"refresh_token": "...",
"user_id": "...",
"email": "authdemo@alloy.dev",
"display_name": "Auth Demo User"
}
The access token is a signed JWT containing your user ID, org ID, email, and
role. Use it in the Authorization header for all authenticated requests:
Authorization: Bearer <access_token>
Refresh
When the access token expires (after 1 hour), use the refresh token to get a new token pair. Alloy implements refresh token rotation — each refresh call returns a brand-new refresh token and revokes the old one:
curl -s -X POST "$BASE_URL/api/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d "{
\"refresh_token\": \"$REFRESH_TOKEN\"
}" | jq .
{
"access_token": "...",
"refresh_token": "...",
"user_id": "...",
"email": "authdemo@alloy.dev",
"display_name": "Auth Demo User"
}
After refreshing, update both tokens in your client. The old refresh token is now invalid and cannot be used again.
Logout
Revoke the refresh token to end the session. This prevents any further token refreshes:
curl -s -X POST "$BASE_URL/api/v1/auth/logout" \
-H "Content-Type: application/json" \
-d "{
\"refresh_token\": \"$REFRESH_TOKEN\"
}" -w "\n%{http_code}" 2>/dev/null | tail -1
A successful logout returns 204 No Content with an empty body. After logout, the refresh token is revoked and cannot be reused.
Security note: Logging out does not immediately invalidate the JWT access token — it remains valid until it expires (up to 1 hour). To force immediate revocation, rotate your JWT signing keys on the server.
4. API Key Management
API keys provide long-lived, scoped authentication for scripts, CI/CD pipelines, and integrations. Unlike JWTs, API keys do not expire automatically (unless you set an expiration) and can be restricted to specific scopes and projects.
Create an API Key
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CI Pipeline Key"
}' | jq .
{
"id": "...",
"name": "CI Pipeline Key",
"key": "...",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
The key field contains the full API key (prefixed with alloy_live_). This
is the only time the full key is shown. Store it securely — Alloy only keeps a
SHA-256 hash.
Use the key exactly like a JWT in the Authorization header:
Authorization: Bearer alloy_live_<key>
List API Keys
View all your API keys. The full key is never returned — only the prefix:
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" | jq .
[
{
"id": "...",
"name": "CI Pipeline Key",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "...",
"last_used_at": null,
"expires_at": null
}
]
Revoke an API Key
Delete a key to immediately revoke it. Use the id from the create or list
response:
curl -s -X DELETE "$BASE_URL/api/v1/api-keys/$API_KEY_ID" \
-H "Authorization: Bearer $TOKEN" -w "\n%{http_code}" 2>/dev/null | tail -1
A successful revocation returns 204 No Content. The key is immediately invalid — any request using it will receive a 401 Unauthorized response.
5. Token Security and Scopes
Scope Model
Alloy enforces scopes on API key requests. JWT-authenticated requests always
receive full access (* scope).
| Scope | Grants |
|---|---|
read | GET requests only |
write | GET, POST, PATCH, PUT, DELETE |
admin | Everything write grants, plus admin operations |
When creating an API key, specify scopes to limit its capabilities:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Read-Only Dashboard Key",
"scopes": ["read"]
}' | jq .
{
"id": "...",
"name": "Read-Only Dashboard Key",
"key": "...",
"key_prefix": "...",
"scopes": ["read"],
"project_ids": [],
"created_at": "...",
"expires_at": null
}
Project-Scoped Keys
Restrict an API key to specific projects. The key can only access resources within the listed projects:
curl -s -X POST "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Frontend Project Key\",
\"scopes\": [\"read\", \"write\"],
\"project_ids\": [\"$PROJECT_ID\"]
}" | jq .
{
"id": "...",
"name": "Frontend Project Key",
"key": "...",
"key_prefix": "...",
"scopes": ["read", "write"],
"project_ids": ["..."],
"created_at": "...",
"expires_at": null
}
Password Security
Alloy hashes all passwords with Argon2, a memory-hard algorithm resistant to GPU and ASIC attacks. Passwords are never stored in plaintext. The minimum password length is 8 characters.
Token Storage
| Token type | Storage | Lifetime |
|---|---|---|
| JWT access token | Client-side only | 1 hour |
| Refresh token | SHA-256 hash in DB | 30 days |
| API key | SHA-256 hash in DB | Until revoked |
6. SSO Authentication (OIDC)
Alloy supports Single Sign-On via OpenID Connect with PKCE. This is used by organizations that require centralized identity management through providers like Okta, Google Workspace, or Azure AD.
SSO Flow
The SSO flow is a two-step redirect process:
Step 1 — Discover the authorization URL:
GET /api/v1/auth/sso/discover?org_id=<org-uuid>
This returns the identity provider’s authorization URL with a PKCE code challenge and a state parameter. Redirect the user’s browser to this URL.
Step 2 — Handle the callback:
GET /api/v1/auth/sso/callback?code=<authorization_code>&state=<state>
After the user authenticates with the identity provider, they are redirected back to Alloy’s callback endpoint. Alloy exchanges the authorization code for tokens, validates the ID token, and returns an Alloy JWT.
How It Works
- Client calls
/api/v1/auth/sso/discoverwith the org ID - Alloy generates a PKCE code verifier and challenge
- Alloy returns the IDP authorization URL with embedded state
- User authenticates at the IDP (Okta, Google, etc.)
- IDP redirects back to
/api/v1/auth/sso/callbackwith an auth code - Alloy exchanges the code for IDP tokens using the PKCE verifier
- Alloy validates the ID token signature via the IDP’s JWKS endpoint
- Alloy finds or creates the user and issues an Alloy JWT
SSO users are automatically added as Member to the organization if they don’t
already have a membership.
Note: SSO configuration (client ID, client secret, issuer URL) is stored per-organization in the database. Contact your Alloy administrator to configure SSO for your organization.
Next Steps
- End-to-End Walkthrough — use your tokens to manage projects, tickets, and sprints
- API Automation — scripting patterns for CI/CD
- API Reference — complete endpoint documentation
MCP Workflow Tutorial — AI-Assisted Project Management
This tutorial walks through a realistic project management workflow using Alloy’s MCP tools with an AI assistant. Each section shows the natural-language prompt you give, the tool the assistant calls, the parameters it sends, and the result you see — so you can follow along in Claude Desktop or Claude Code.
Prerequisites: Alloy is running with the demo seed data loaded (
./scripts/seed-demo.sh). The MCP server is connected to your AI assistant (see MCP Guide). The demo data creates an Acme Corp organization with a DEMO project, 6 tickets, a sprint, comments, and time entries.
1. Verify Connectivity
Before doing any real work, confirm the MCP connection is live.
Prompt:
Check my Alloy connection
Tool called: ping
Parameters: (none)
Result:
Alloy MCP connected to http://localhost:3000 as demo@alloy.dev (org acme-corp)
2. Check Your Identity
See who you are authenticated as and which organization you belong to.
Prompt:
Who am I in Alloy?
Tool called: whoami
Parameters: (none)
Result:
{
"user_id": "b2f0c4e8-...",
"org_id": "a1e0d3f7-...",
"email": "demo@alloy.dev",
"role": "Admin"
}
3. Get the Project Summary
Get a high-level view of the DEMO project — ticket counts by status and active sprints.
Prompt:
Give me a summary of the DEMO project
Tool called: get_project_summary
Parameters:
| Parameter | Value |
|---|---|
project_id | <DEMO project UUID> |
Result:
{
"project": {
"id": "c3a1b5d9-...",
"name": "Demo Project",
"key": "DEMO",
"description": "A demo project for Alloy"
},
"ticket_summary": {
"total": 6,
"by_status": {
"Backlog": 3,
"InProgress": 2,
"Done": 1
},
"has_more": false
},
"sprints": [
{
"id": "d4b2c6ea-...",
"name": "Sprint 1",
"status": "Active",
"start_date": "2026-03-28",
"end_date": "2026-04-11"
}
]
}
The project has 6 tickets: 3 in Backlog, 2 in progress, and 1 done. Sprint 1 is active.
4. Search for High-Priority Tickets
Find all high-priority tickets to decide what to work on next.
Prompt:
Show me all High priority tickets in the DEMO project
Tool called: search_tickets
Parameters:
| Parameter | Value |
|---|---|
project_id | <DEMO project UUID> |
priority | High |
Result:
{
"data": [
{
"id": "e5c3d7fb-...",
"ticket_number": 1,
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "High",
"assignee_id": null
},
{
"id": "f6d4e80c-...",
"ticket_number": 2,
"title": "Fix login redirect",
"status": "InProgress",
"priority": "High",
"assignee_id": null
},
{
"id": "a7e5f91d-...",
"ticket_number": 6,
"title": "Deploy staging",
"status": "Backlog",
"priority": "High",
"assignee_id": null
}
],
"next_cursor": null,
"has_more": false
}
Three high-priority tickets. “Fix login redirect” is already in progress; “Set up CI pipeline” and “Deploy staging” are still in the backlog.
5. Assign a Ticket to Yourself
Pick up “Set up CI pipeline” by assigning it to yourself.
Prompt:
Assign the “Set up CI pipeline” ticket to me
Tool called: assign_ticket
Parameters:
| Parameter | Value |
|---|---|
ticket_id | <Set up CI pipeline ticket UUID> |
assignee_id | <your user UUID> |
Result:
{
"id": "e5c3d7fb-...",
"ticket_number": 1,
"title": "Set up CI pipeline",
"assignee_id": "b2f0c4e8-...",
"updated_at": "2026-03-29T10:05:00Z"
}
The ticket is now assigned to you.
6. Transition a Ticket Through the Workflow
Move “Set up CI pipeline” from Backlog to Todo to start planning the work.
Prompt:
Move “Set up CI pipeline” to Todo
Tool called: transition_ticket
Parameters:
| Parameter | Value |
|---|---|
ticket_id | <Set up CI pipeline ticket UUID> |
to_status | Todo |
Result:
{
"id": "e5c3d7fb-...",
"status": "Todo",
"updated_at": "2026-03-29T10:10:00Z"
}
Now try an invalid transition — skip straight from Todo to Done:
Prompt:
Move “Set up CI pipeline” to Done
Tool called: transition_ticket
Parameters:
| Parameter | Value |
|---|---|
ticket_id | <Set up CI pipeline ticket UUID> |
to_status | Done |
Result (error):
Transition failed: Cannot transition from Todo to Done. Current status: Todo. Available transitions: [InProgress, Cancelled]
The workflow enforces valid transitions. From Todo, you can only move to InProgress or Cancelled. This prevents tickets from skipping review stages.
7. Add a Comment to a Ticket
Leave a note on the ticket with your implementation plan.
Prompt:
Add a comment to “Set up CI pipeline” saying “Will use GitHub Actions with Rust caching. ETA: end of sprint.”
Tool called: add_comment
Parameters:
| Parameter | Value |
|---|---|
ticket_id | <Set up CI pipeline ticket UUID> |
body | Will use GitHub Actions with Rust caching. ETA: end of sprint. |
Result:
{
"id": "b8f6a02e-...",
"ticket_id": "e5c3d7fb-...",
"author_id": "b2f0c4e8-...",
"body": "Will use GitHub Actions with Rust caching. ETA: end of sprint.",
"parent_comment_id": null,
"created_at": "2026-03-29T10:15:00Z"
}
8. Log Time on a Ticket
After working on the CI setup, log the time you spent.
Prompt:
Log 2 hours of Coding on “Set up CI pipeline” for today
Tool called: log_time
Parameters:
| Parameter | Value |
|---|---|
ticket_id | <Set up CI pipeline ticket UUID> |
project_id | <DEMO project UUID> |
date | 2026-03-29 |
duration_minutes | 120 |
activity_type | Coding |
Result:
{
"id": "c9a7b13f-...",
"user_id": "b2f0c4e8-...",
"ticket_id": "e5c3d7fb-...",
"project_id": "c3a1b5d9-...",
"date": "2026-03-29",
"duration_minutes": 120,
"activity_type": "Coding",
"description": null,
"status": "Draft",
"created_at": "2026-03-29T12:15:00Z"
}
The time entry starts in Draft status. It can later be submitted for approval.
9. Create a New Ticket
While working on CI, you discover a blocker. Create a ticket for it.
Prompt:
Create a ticket in the DEMO project titled “Fix Docker build cache invalidation” with priority Urgent and description “Docker layer caching breaks on dependency updates, causing 20-minute builds”
Tool called: create_ticket
Parameters:
| Parameter | Value |
|---|---|
project_id | <DEMO project UUID> |
title | Fix Docker build cache invalidation |
description | Docker layer caching breaks on dependency updates, causing 20-minute builds |
priority | Urgent |
Result:
{
"id": "d0b8c24a-...",
"ticket_number": 7,
"project_id": "c3a1b5d9-...",
"title": "Fix Docker build cache invalidation",
"description": "Docker layer caching breaks on dependency updates, causing 20-minute builds",
"status": "Backlog",
"priority": "Urgent",
"assignee_id": null,
"reporter_id": "b2f0c4e8-...",
"created_at": "2026-03-29T12:20:00Z",
"updated_at": "2026-03-29T12:20:00Z"
}
The new ticket is DEMO-7, automatically placed in Backlog.
10. Review the Sprint Burndown
End the session by checking how the sprint is tracking.
Prompt:
Show me the burndown chart for Sprint 1
Tool called: get_sprint_burndown
Parameters:
| Parameter | Value |
|---|---|
sprint_id | <Sprint 1 UUID> |
Result:
{
"sprint_id": "d4b2c6ea-...",
"data_points": [
{ "date": "2026-03-28", "total": 6, "completed": 1, "remaining": 5 },
{ "date": "2026-03-29", "total": 6, "completed": 1, "remaining": 5 }
]
}
Sprint 1 has 6 tickets with 1 completed so far. The burndown shows daily progress toward completing all sprint work by the end date (2026-04-11).
Summary
This workflow covered 10 of Alloy’s 49 MCP tools:
| Step | Tool | Purpose |
|---|---|---|
| 1 | ping | Verify connectivity |
| 2 | whoami | Check authenticated identity |
| 3 | get_project_summary | High-level project overview |
| 4 | search_tickets | Find tickets by priority |
| 5 | assign_ticket | Take ownership of work |
| 6 | transition_ticket | Move tickets through workflow (+ error case) |
| 7 | add_comment | Collaborate with notes |
| 8 | log_time | Track time spent |
| 9 | create_ticket | Report new work |
| 10 | get_sprint_burndown | Monitor sprint progress |
The remaining 39 tools — including project CRUD (create_project, list_projects,
get_project, update_project, delete_project), label management
(create_label, list_labels, add_ticket_label, remove_ticket_label),
tag management (set_tags, get_tags, delete_tag, search_by_tag),
sprint lifecycle (create_sprint, list_sprints, update_sprint,
start_sprint, complete_sprint), workflow management (create_workflow,
list_workflows, update_workflow), organization & access control
(list_members, create_invite, list_invites, add_project_member,
remove_project_member, list_project_members, create_api_key,
list_api_keys, delete_api_key), activity (get_ticket_activity),
and finance (get_time_report, get_capitalization_report,
submit_time_entry, approve_time_entry) — are covered in the
MCP Tools Reference.
For API-level details and curl equivalents, see the End-to-End Walkthrough.
Architecture Overview
Alloy is a headless, API-first project management service built in Rust. It supports two database backends (PostgreSQL multi-tenant, SQLite single-tenant) and exposes its capabilities through an HTTP API, a CLI, a TUI, and an MCP server.
Crate Dependency Graph
┌────────────┐
│ alloy-core │ (domain models, service traits, repo traits)
└─────┬──────┘
│
┌─────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────────┐
│ alloy-api │ │ alloy-cli │ │ alloy-test-utils │
│ (Axum, │ │ (Clap CLI, │ │ (test builders, │
│ SQLx, │ │ depends on │ │ seeders, │
│ repos) │ │ alloy-api │ │ fake data) │
└──────┬─────┘ │ + core) │ └──────────────────┘
│ └──────────────┘
│
┌──────┴─────┐
│ alloy-mcp │ (MCP server — HTTP client to alloy-api)
└────────────┘
┌────────────┐
│ alloy-tui │ (Ratatui TUI — HTTP client to alloy-api)
└────────────┘
Key rules:
alloy-corehas zero framework dependencies — no Axum, no SQLx, no Tokio. It contains only domain models, enums, service traits, and repository trait definitions.alloy-apidepends onalloy-coreand provides concrete SQLx-backed repository implementations plus Axum HTTP handlers.alloy-clidepends on bothalloy-apiandalloy-core(it embeds the server).alloy-mcpandalloy-tuiare HTTP clients — they talk to a runningalloy-apiinstance over the network and do not link against it.alloy-test-utilsdepends onalloy-coreand provides shared test helpers.
Request Lifecycle
A typical authenticated API request flows through these layers:
HTTP Request
│
▼
┌──────────────────────┐
│ Axum Router │ Route matching
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Rate Limit Layer │ Token-bucket rate limiting (tower middleware)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Security Headers │ CORS, CSP, HSTS, X-Frame-Options
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Tenant Context │ Extracts org_id from token, sets task-local
│ Middleware │ for PostgreSQL RLS (see Tenant Isolation below)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Auth Extractor │ Validates JWT or API key from Authorization header
│ (AuthContext) │ Produces AuthContext: user_id, org_id, role, scopes
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Handler Function │ Business-level validation, converts AuthContext
│ (handlers/*.rs) │ to ActorContext, calls service methods
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Service Layer │ Domain logic: permission checks, audit logging,
│ (alloy-core │ workflow transitions, business rules
│ services/*.rs) │ Operates on trait-based repos (no DB knowledge)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Repository Impl │ SQLx queries against PostgreSQL or SQLite
│ (alloy-api │ PG repos use begin_tenant_tx() for RLS
│ repos/*.rs) │ SQLite repos query directly with org_id filter
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Database │ PostgreSQL 16 or SQLite (WAL mode)
└──────────────────────┘
Auth Flow Detail
- The
Authorization: Bearer <token>header is read by theAuthContextextractor (an AxumFromRequestPartsimplementation). - Tokens prefixed
alloy_live_oralloy_test_are validated as API keys — the key hash is looked up in the database, and the associated user, org, role, scopes, and project restrictions are loaded. - All other tokens are validated as JWTs — signature verification uses the
configured HMAC secret. JWT auth always grants
scopes: "*". - The resulting
AuthContextcarriesuser_id,org_id,email,role,scopes, andallowed_project_idsfor downstream use. - Handlers convert
AuthContextintoActorContext(a framework-free struct inalloy-core) before calling service methods.
SQLite vs PostgreSQL Paths
Alloy supports two database backends selected by the ALLOY_DATABASE_URL
environment variable:
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Use case | Multi-tenant production | Single-tenant / local dev |
| Connection | postgres://... | sqlite://path or sqlite::memory: |
| Tenant isolation | Row Level Security (RLS) | org_id column filters in queries |
| Migrations | migrations/postgres/ | migrations/sqlite/ |
| Migration runner | refinery embed_migrations! | refinery embed_migrations! |
| UUID storage | Native UUID type | TEXT |
| Timestamps | TIMESTAMPTZ | TEXT (ISO 8601) |
| Auto-increment | SERIAL / BIGSERIAL | INTEGER PRIMARY KEY |
| Journal mode | WAL by default | WAL enabled at connection |
| Pool type | sqlx::PgPool | sqlx::SqlitePool |
The Database enum in alloy-api/src/db.rs wraps both backends behind a
uniform interface. Migrations are compiled into the binary via
refinery::embed_migrations! and run automatically on startup when
ALLOY_AUTO_MIGRATE=true.
Repository traits are defined once in alloy-core/src/repos.rs. Each trait
has two implementations in alloy-api/src/repos/:
pg_<domain>.rs— PostgreSQL implementation usingsqlx::PgPoolsqlite_<domain>.rs— SQLite implementation usingsqlx::SqlitePool
Tenant Isolation Model
Alloy enforces strict data isolation between organisations (tenants).
PostgreSQL: Row Level Security
- Every table has an
org_idcolumn. - RLS policies restrict
SELECT,INSERT,UPDATE, andDELETEto rows whereorg_idmatches the session variableapp.tenant_id. - The tenant context middleware runs before every request. It extracts
org_idfrom the auth token and stores it in atokio::task_local. - PostgreSQL repository methods call
begin_tenant_tx()which starts a transaction and executesSET LOCAL app.tenant_id = '<org_id>'. This activates RLS filtering for the duration of the transaction. - RLS failures are silent — they return fewer rows, not errors. Tests must assert zero results across tenant boundaries, not expect errors.
Request with Org A token
│
▼
tenant_context_middleware ──► task_local CURRENT_TENANT = Org A
│
▼
PG repo: begin_tenant_tx()
│
▼
SET LOCAL app.tenant_id = 'org-a-uuid'
│
▼
SELECT * FROM tickets WHERE ...
(RLS automatically adds: AND org_id = 'org-a-uuid')
SQLite: Explicit Filtering
SQLite has no RLS. Instead:
- Every query includes
AND org_id = ?in itsWHEREclause. - The
org_idparameter comes from theActorContextpassed through the service layer. - This provides defence-in-depth alongside application-level permission checks in the service layer.
Application-Level Checks
Both backends also enforce:
- Ownership checks in service methods (e.g. only the author of a time entry can submit it).
- Role-based access via
ActorContext.role(owner, admin, member, viewer). - Scope restrictions via
ActorContext.scopes(API keys can be limited to read-only or specific operations). - Project restrictions via
ActorContext.allowed_project_ids(API keys can be scoped to specific projects).
Database Schema Reference
Generated from migration files in
migrations/postgres/andmigrations/sqlite/. Migrations V1–V32. Last updated: 2026-04-02.
SQLite vs PostgreSQL Differences
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Primary keys | UUID DEFAULT gen_random_uuid() | TEXT (app-generated UUIDs) |
| Timestamps | TIMESTAMPTZ DEFAULT now() | TEXT DEFAULT (datetime('now')) |
| Booleans | BOOLEAN (TRUE/FALSE) | INTEGER (1/0) |
| JSON columns | JSONB | TEXT (JSON stored as string) |
| Large integers | BIGINT | INTEGER |
| Multi-tenancy | Row Level Security (RLS) policies | Explicit AND org_id = ? in queries |
| UUID function | gen_random_uuid() | App-generated; migrations use hex(randomblob(...)) |
| Tenant context | current_setting('app.tenant_id')::uuid | Passed as query parameter |
Tables
schema_info
Tracks the Alloy version embedded in the database. Created in V1.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| key | TEXT | TEXT | PK |
| value | TEXT | TEXT | NOT NULL |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
organizations
Top-level tenant entity. Created in V2.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| name | TEXT | TEXT | NOT NULL |
| slug | TEXT | TEXT | NOT NULL, UNIQUE |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): id = current_tenant_id() (V9)
users
Application users. Created in V3, amended in V22.
| Column | PG Type | SQLite Type | Constraints | Migration |
|---|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) | V3 |
| TEXT | TEXT | NOT NULL, UNIQUE | V3 | |
| display_name | TEXT | TEXT | NOT NULL | V3 |
| password_hash | TEXT | TEXT | Nullable | V3 |
| active | BOOLEAN | INTEGER | NOT NULL, DEFAULT TRUE/1 | V22 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V3 |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V3 |
Note: Users are not tenant-scoped — a user can belong to multiple organizations via org_memberships.
org_memberships
Links users to organizations with a role. Created in V4.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| user_id | UUID | TEXT | PK (composite), FK → users(id) |
| org_id | UUID | TEXT | PK (composite), FK → organizations(id) |
| role | TEXT | TEXT | NOT NULL |
| joined_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): org_id = current_tenant_id() (V9)
teams
Groups of users within an organization. Created in V5.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) |
| name | TEXT | TEXT | NOT NULL |
| description | TEXT | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): org_id = current_tenant_id() (V9)
team_memberships
Links users to teams. Created in V5.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| user_id | UUID | TEXT | PK (composite), FK → users(id) |
| team_id | UUID | TEXT | PK (composite), FK → teams(id) |
| joined_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
projects
A project within an organization, optionally assigned to a team. Created in V6, amended in V14 (workflow_id), V17 (capitalization fields), V27 (budget fields).
| Column | PG Type | SQLite Type | Constraints | Migration |
|---|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) | V6 |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) | V6 |
| team_id | UUID | TEXT | Nullable, FK → teams(id) | V6 |
| key | TEXT | TEXT | NOT NULL, UNIQUE(org_id, key) | V6 |
| name | TEXT | TEXT | NOT NULL | V6 |
| description | TEXT | TEXT | Nullable | V6 |
| ticket_counter | INTEGER | INTEGER | NOT NULL, DEFAULT 0 | V6 |
| workflow_id | UUID | TEXT | Nullable, FK → workflows(id) | V14 |
| capitalization_type | TEXT | TEXT | Nullable | V17 |
| development_phase | TEXT | TEXT | Nullable | V17 |
| cost_center_id | TEXT | TEXT | Nullable | V17 |
| amortization_months | INTEGER | INTEGER | Nullable | V17 |
| budget_cents | BIGINT | INTEGER | Nullable | V27 |
| budget_period | TEXT | TEXT | Nullable | V27 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V6 |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V6 |
RLS (PG): org_id = current_tenant_id() (V9)
tickets
Work items within a project. Created in V7, amended in V15 (sprint_id), V31 (indexes).
| Column | PG Type | SQLite Type | Constraints | Migration |
|---|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) | V7 |
| project_id | UUID | TEXT | NOT NULL, FK → projects(id) | V7 |
| ticket_number | INTEGER | INTEGER | NOT NULL, UNIQUE(project_id, ticket_number) | V7 |
| title | TEXT | TEXT | NOT NULL | V7 |
| description | TEXT | TEXT | Nullable | V7 |
| status | TEXT | TEXT | NOT NULL, DEFAULT ‘Backlog’ | V7 |
| priority | TEXT | TEXT | NOT NULL, DEFAULT ‘None’ | V7 |
| assignee_id | UUID | TEXT | Nullable, FK → users(id) | V7 |
| reporter_id | UUID | TEXT | NOT NULL, FK → users(id) | V7 |
| sprint_id | UUID | TEXT | Nullable, FK → sprints(id) ON DELETE SET NULL | V15 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V7 |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V7 |
Indexes: idx_tickets_project_status(project_id, status), idx_tickets_assignee_id(assignee_id), idx_tickets_sprint_id(sprint_id) (V31)
RLS (PG): project_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id()) (V9)
api_keys
API key credentials for programmatic access. Created in V8, amended in V25 (project_ids).
| Column | PG Type | SQLite Type | Constraints | Migration |
|---|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) | V8 |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) | V8 |
| user_id | UUID | TEXT | NOT NULL, FK → users(id) | V8 |
| name | TEXT | TEXT | NOT NULL | V8 |
| key_prefix | TEXT | TEXT | NOT NULL | V8 |
| key_hash | TEXT | TEXT | NOT NULL, UNIQUE | V8 |
| scopes | TEXT | TEXT | NOT NULL, DEFAULT ‘*’ | V8 |
| project_ids | TEXT | TEXT | NOT NULL, DEFAULT ‘’ | V25 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now | V8 |
| last_used_at | TIMESTAMPTZ | TEXT | Nullable | V8 |
| expires_at | TIMESTAMPTZ | TEXT | Nullable | V8 |
Indexes: idx_api_keys_key_hash(key_hash) UNIQUE
comments
Threaded comments on tickets. Created in V10.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| ticket_id | UUID | TEXT | NOT NULL, FK → tickets(id) ON DELETE CASCADE |
| author_id | UUID | TEXT | NOT NULL, FK → users(id) |
| body | TEXT | TEXT | NOT NULL |
| parent_comment_id | UUID | TEXT | Nullable, FK → comments(id) ON DELETE CASCADE |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_comments_ticket_id(ticket_id), idx_comments_author_id(author_id), idx_comments_parent_id(parent_comment_id)
audit_logs
Immutable log of all mutations. Created in V11.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| entity_type | TEXT | TEXT | NOT NULL |
| entity_id | TEXT | TEXT | NOT NULL |
| action | TEXT | TEXT | NOT NULL |
| actor_id | UUID | TEXT | NOT NULL |
| changes | TEXT | TEXT | NOT NULL, DEFAULT ‘[]’ |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_audit_logs_entity(entity_type, entity_id), idx_audit_logs_actor(actor_id), idx_audit_logs_created(created_at)
attachments
File attachments on tickets. Created in V12.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| ticket_id | UUID | TEXT | NOT NULL, FK → tickets(id) ON DELETE CASCADE |
| filename | TEXT | TEXT | NOT NULL |
| content_type | TEXT | TEXT | NOT NULL |
| size_bytes | BIGINT | INTEGER | NOT NULL |
| s3_key | TEXT | TEXT | NOT NULL |
| uploaded_by | UUID | TEXT | NOT NULL, FK → users(id) |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_attachments_ticket_id(ticket_id), idx_attachments_uploaded_by(uploaded_by)
RLS (PG): Via ticket → project → org join (V12)
labels
Organization-scoped labels for categorizing tickets. Created in V13.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) ON DELETE CASCADE |
| name | TEXT | TEXT | NOT NULL |
| color | TEXT | TEXT | NOT NULL |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_labels_org_id(org_id), idx_labels_org_name(org_id, name) UNIQUE
RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V13)
ticket_labels
Many-to-many join between tickets and labels. Created in V13.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| ticket_id | UUID | TEXT | PK (composite), FK → tickets(id) ON DELETE CASCADE |
| label_id | UUID | TEXT | PK (composite), FK → labels(id) ON DELETE CASCADE |
Indexes: idx_ticket_labels_label_id(label_id)
RLS (PG): Via label → org join (V13)
workflows
Configurable status workflows per organization. Created in V14, amended in V28 (seed data), V29 (enforcement).
| Column | PG Type | SQLite Type | Constraints | Migration |
|---|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) | V14 |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) | V14 |
| name | TEXT | TEXT | NOT NULL, UNIQUE(org_id, name) | V14 |
| statuses | JSONB | TEXT | NOT NULL, DEFAULT ‘[]’ | V14 |
| transitions | JSONB | TEXT | NOT NULL, DEFAULT ‘[]’ | V14 |
| enforcement | TEXT | TEXT | NOT NULL, DEFAULT ‘none’ | V29 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL | V14 |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL | V14 |
RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V14)
Seed data (V28): A “Default” workflow with 6 statuses (Backlog, Todo, InProgress, InReview, Done, Cancelled) and all-to-all transitions is created for every existing organization.
sprints
Time-boxed iterations within a project. Created in V15.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK |
| project_id | UUID | TEXT | NOT NULL, FK → projects(id) ON DELETE CASCADE |
| name | TEXT | TEXT | NOT NULL |
| goal | TEXT | TEXT | Nullable |
| start_date | TEXT | TEXT | NOT NULL |
| end_date | TEXT | TEXT | NOT NULL |
| status | TEXT | TEXT | NOT NULL, DEFAULT ‘Planned’ |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_sprints_project_id(project_id)
RLS (PG): Via project → org join (V15)
time_entries
Logged time against tickets for finance/capitalization. Created in V16, amended in V32 (indexes).
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK |
| user_id | UUID | TEXT | NOT NULL, FK → users(id) |
| ticket_id | UUID | TEXT | NOT NULL, FK → tickets(id) ON DELETE CASCADE |
| project_id | UUID | TEXT | NOT NULL, FK → projects(id) ON DELETE CASCADE |
| date | TEXT | TEXT | NOT NULL |
| duration_minutes | INTEGER | INTEGER | NOT NULL |
| description | TEXT | TEXT | Nullable |
| activity_type | TEXT | TEXT | NOT NULL |
| status | TEXT | TEXT | NOT NULL, DEFAULT ‘Draft’ |
| approved_by | UUID | TEXT | Nullable, FK → users(id) |
| approved_at | TIMESTAMPTZ | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_time_entries_user_id(user_id), idx_time_entries_ticket_id(ticket_id), idx_time_entries_project_id(project_id), idx_time_entries_date(date), idx_time_entries_user_date(user_id, date) (V32), idx_time_entries_project_date(project_id, date) (V32)
RLS (PG): Via project → org join (V16)
user_labor_rates
Per-user billing rates for capitalization calculations. Created in V18.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK |
| user_id | UUID | TEXT | NOT NULL, FK → users(id) |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) |
| loaded_rate_cents | INTEGER | INTEGER | NOT NULL |
| effective_date | TEXT | TEXT | NOT NULL |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_user_labor_rates_user_id(user_id), idx_user_labor_rates_org_id(org_id), idx_user_labor_rates_effective_date(effective_date), idx_user_labor_rates_user_org_date(user_id, org_id, effective_date) UNIQUE
RLS (PG): Via org join (V18)
webhooks
Outbound webhook configurations. Created in V19.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) |
| url | TEXT | TEXT | NOT NULL |
| secret | TEXT | TEXT | NOT NULL |
| event_types | TEXT | TEXT | NOT NULL |
| active | BOOLEAN | INTEGER | NOT NULL, DEFAULT TRUE/1 |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_webhooks_org_id(org_id), idx_webhooks_active(active)
RLS (PG): Via org join (V19)
webhook_deliveries
Delivery attempts for webhooks. Created in V19.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK |
| webhook_id | UUID | TEXT | NOT NULL, FK → webhooks(id) ON DELETE CASCADE |
| event_type | TEXT | TEXT | NOT NULL |
| payload | TEXT | TEXT | NOT NULL |
| status | TEXT | TEXT | NOT NULL, DEFAULT ‘pending’ |
| response_status | INTEGER | INTEGER | Nullable |
| response_body | TEXT | TEXT | Nullable |
| attempt | INTEGER | INTEGER | NOT NULL, DEFAULT 0 |
| next_retry_at | TIMESTAMPTZ | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_webhook_deliveries_webhook_id(webhook_id), idx_webhook_deliveries_status(status)
slack_thread_mappings
Maps Slack threads to Alloy tickets. Created in V20.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | TEXT | TEXT | PK |
| tenant_id | TEXT | — | NOT NULL, DEFAULT current_setting(‘app.tenant_id’) (PG only) |
| ticket_id | TEXT | TEXT | NOT NULL, UNIQUE(ticket_id, channel_id) |
| channel_id | TEXT | TEXT | NOT NULL |
| thread_ts | TEXT | TEXT | NOT NULL |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): tenant_id = current_setting('app.tenant_id') (V20)
Note: The tenant_id column exists only in PostgreSQL. SQLite is single-tenant.
identity_providers
SSO/OIDC configuration per organization. Created in V21.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| org_id | UUID | TEXT | NOT NULL, UNIQUE, FK → organizations(id) |
| provider_type | TEXT | TEXT | NOT NULL, DEFAULT ‘oidc’ |
| provider_name | TEXT | TEXT | NOT NULL, DEFAULT ‘okta’ |
| issuer_url | TEXT | TEXT | NOT NULL |
| client_id | TEXT | TEXT | NOT NULL |
| client_secret | TEXT | TEXT | NOT NULL |
| authorization_endpoint | TEXT | TEXT | Nullable |
| token_endpoint | TEXT | TEXT | Nullable |
| jwks_uri | TEXT | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): org_id = current_setting('app.tenant_id')::UUID (V21)
invites
Organization membership invitations. Created in V23.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) |
| TEXT | TEXT | Nullable | |
| invite_code | TEXT | TEXT | NOT NULL, UNIQUE |
| role | TEXT | TEXT | NOT NULL, DEFAULT ‘Member’ |
| created_by | UUID | TEXT | NOT NULL, FK → users(id) |
| expires_at | TIMESTAMPTZ | TEXT | NOT NULL |
| accepted_at | TIMESTAMPTZ | TEXT | Nullable |
| revoked_at | TIMESTAMPTZ | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): org_id::text = current_setting('app.tenant_id', true) (V23)
refresh_tokens
JWT refresh token tracking. Created in V24.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| user_id | UUID | TEXT | NOT NULL, FK → users(id) |
| token_hash | TEXT | TEXT | NOT NULL, UNIQUE |
| expires_at | TIMESTAMPTZ | TEXT | NOT NULL |
| revoked_at | TIMESTAMPTZ | TEXT | Nullable |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_refresh_tokens_user_id(user_id), idx_refresh_tokens_token_hash(token_hash)
entity_tags
Key-value tags on any entity (projects, tickets, etc.). Created in V26.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| id | UUID | TEXT | PK, DEFAULT gen_random_uuid() (PG) |
| org_id | UUID | TEXT | NOT NULL, FK → organizations(id) ON DELETE CASCADE |
| entity_type | TEXT | TEXT | NOT NULL |
| entity_id | TEXT | TEXT | NOT NULL |
| tag_key | TEXT | TEXT | NOT NULL |
| tag_value | TEXT | TEXT | NOT NULL |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
| updated_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
Indexes: idx_entity_tags_org_id(org_id), idx_entity_tags_entity(org_id, entity_type, entity_id), idx_entity_tags_unique(org_id, entity_type, entity_id, tag_key) UNIQUE, idx_entity_tags_search(org_id, tag_key, tag_value)
RLS (PG): org_id = current_setting('app.tenant_id')::uuid (V26)
project_memberships
Links users to projects they can access. Created in V30.
| Column | PG Type | SQLite Type | Constraints |
|---|---|---|---|
| project_id | UUID | TEXT | PK (composite), FK → projects(id) ON DELETE CASCADE |
| user_id | UUID | TEXT | PK (composite), FK → users(id) ON DELETE CASCADE |
| created_at | TIMESTAMPTZ | TEXT | NOT NULL, DEFAULT now |
RLS (PG): Via project → org join (V30)
RLS Summary (PostgreSQL Only)
V9 creates the current_tenant_id() helper function and enables RLS on the core tables. Subsequent migrations (V12–V30) enable RLS on each new table as it’s created.
| Table | RLS Policy | Isolation Path |
|---|---|---|
| organizations | id = current_tenant_id() | Direct |
| projects | org_id = current_tenant_id() | Direct |
| tickets | Via project → org | Indirect |
| teams | org_id = current_tenant_id() | Direct |
| org_memberships | org_id = current_tenant_id() | Direct |
| attachments | Via ticket → project → org | Indirect |
| labels | org_id = current_setting(...)::uuid | Direct |
| ticket_labels | Via label → org | Indirect |
| workflows | org_id = current_setting(...)::uuid | Direct |
| sprints | Via project → org | Indirect |
| time_entries | Via project → org | Indirect |
| user_labor_rates | Via org | Indirect |
| webhooks | Via org | Indirect |
| webhook_deliveries | (Via webhook — no direct RLS) | N/A |
| slack_thread_mappings | tenant_id = current_setting(...) | Direct (own column) |
| identity_providers | org_id = current_setting(...)::UUID | Direct |
| invites | org_id::text = current_setting(...) | Direct |
| entity_tags | org_id = current_setting(...)::uuid | Direct |
| project_memberships | Via project → org | Indirect |
V9 also creates the app_user role with SELECT/INSERT/UPDATE/DELETE on all tables and grants default privileges for future tables.
Entity Relationship Overview
organizations ─┬── org_memberships ── users
├── teams ── team_memberships ── users
├── projects ─┬── tickets ─┬── comments
│ │ ├── attachments
│ │ ├── ticket_labels ── labels
│ │ └── time_entries
│ ├── sprints (tickets.sprint_id)
│ └── project_memberships ── users
├── workflows (projects.workflow_id)
├── labels
├── webhooks ── webhook_deliveries
├── identity_providers
├── invites
├── user_labor_rates
├── entity_tags
└── slack_thread_mappings (via tickets)
users ─┬── api_keys
└── refresh_tokens
audit_logs (standalone, references entities by type+id)
schema_info (standalone metadata)
Migration History
| Version | Description |
|---|---|
| V1 | Create schema_info |
| V2 | Create organizations |
| V3 | Create users |
| V4 | Create org_memberships |
| V5 | Create teams + team_memberships |
| V6 | Create projects |
| V7 | Create tickets |
| V8 | Create api_keys |
| V9 | Enable RLS (PG); no-op (SQLite) |
| V10 | Create comments |
| V11 | Create audit_logs |
| V12 | Create attachments |
| V13 | Create labels + ticket_labels |
| V14 | Create workflows; add projects.workflow_id |
| V15 | Create sprints; add tickets.sprint_id |
| V16 | Create time_entries |
| V17 | Add project capitalization fields |
| V18 | Create user_labor_rates |
| V19 | Create webhooks + webhook_deliveries |
| V20 | Create slack_thread_mappings |
| V21 | Create identity_providers |
| V22 | Add users.active |
| V23 | Create invites |
| V24 | Create refresh_tokens |
| V25 | Add api_keys.project_ids |
| V26 | Create entity_tags |
| V27 | Add project budget fields |
| V28 | Seed default workflows |
| V29 | Add workflows.enforcement |
| V30 | Create project_memberships |
| V31 | Add ticket indexes |
| V32 | Add time_entry indexes |
Service Layer Patterns
The service layer lives in crates/alloy-core/src/services/ and encapsulates
all business logic — validation, ownership checks, permission gates, and audit
logging. Services are framework-free: no Axum, no SQLx, no Tokio. They depend
only on repository traits defined in alloy-core.
ActorContext
ActorContext is the framework-free caller identity. Handlers convert their
Axum-specific AuthContext into an ActorContext before calling service
methods.
Defined in: crates/alloy-core/src/services/mod.rs
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct ActorContext {
pub user_id: UserId,
pub org_id: OrgId,
pub email: String,
pub role: OrgRole,
pub scopes: String,
pub allowed_project_ids: String,
}
}
| Field | Type | Purpose |
|---|---|---|
user_id | UserId | Authenticated caller’s ID |
org_id | OrgId | Organisation the request is scoped to |
email | String | User’s email address |
role | OrgRole | Hierarchical role (Owner > Admin > Member > Reporter > Viewer) |
scopes | String | Comma-separated scopes or "*" for full access |
allowed_project_ids | String | Comma-separated project IDs, or empty for unrestricted |
Key methods:
has_scope(scope: &str) -> bool— checks scope membership (wildcard and admin escalation rules apply)has_project_access(project_id: &str) -> bool— verifies the caller can access a specific projecthas_role(minimum: &OrgRole) -> bool— checks role hierarchy
ServiceError
ServiceError is the domain error type returned by all service methods. It
maps cleanly to HTTP status codes via a From impl in alloy-api.
Defined in: crates/alloy-core/src/services/mod.rs
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("not found: {0}")]
NotFound(String),
#[error("already exists: {0}")]
AlreadyExists(String),
#[error("constraint violation: {0}")]
ConstraintViolation(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("validation error: {0}")]
Validation(String),
#[error("internal error: {0}")]
Internal(String),
}
}
Each variant wraps a String message. Uses thiserror::Error for automatic
Display and std::error::Error implementations.
From ServiceError for ApiError
Defined in: crates/alloy-api/src/error.rs
The conversion maps domain errors to HTTP semantics:
#![allow(unused)]
fn main() {
impl From<ServiceError> for ApiError {
fn from(err: ServiceError) -> Self {
match err {
ServiceError::NotFound(msg) => Self::NotFound(msg), // 404
ServiceError::AlreadyExists(msg) => Self::Conflict(msg, vec![]), // 409
ServiceError::ConstraintViolation(msg)
| ServiceError::Validation(msg) => Self::Validation(vec![msg]), // 422
ServiceError::PermissionDenied(msg) => Self::Forbidden(msg), // 403
ServiceError::Internal(msg) => Self::Internal(msg), // 500
}
}
}
}
| ServiceError | ApiError | HTTP Status |
|---|---|---|
NotFound | NotFound | 404 |
AlreadyExists | Conflict | 409 |
ConstraintViolation | Validation | 422 |
Validation | Validation | 422 |
PermissionDenied | Forbidden | 403 |
Internal | Internal | 500 |
Handlers can use ? to propagate service errors — Axum’s IntoResponse
serialises the ApiError as JSON automatically.
record_audit Helper
A pure function that builds audit log entries without touching a repository.
Callers pass the result to AuditLogRepository::create().
Defined in: crates/alloy-core/src/services/mod.rs
#![allow(unused)]
fn main() {
pub fn record_audit(
actor: &ActorContext,
entity_type: impl Into<String>,
entity_id: impl Into<String>,
action: AuditAction,
changes: Vec<FieldChange>,
) -> CreateAuditLogEntry
}
| Parameter | Type | Purpose |
|---|---|---|
actor | &ActorContext | Caller identity for attribution |
entity_type | impl Into<String> | Resource type (e.g. "ticket", "project") |
entity_id | impl Into<String> | ID of the affected entity |
action | AuditAction | Create, Update, or Delete |
changes | Vec<FieldChange> | Field-level mutations (may be empty) |
Usage:
#![allow(unused)]
fn main() {
let audit = record_audit(
actor,
"ticket",
&ticket.id.0.to_string(),
AuditAction::Update,
vec![FieldChange { field: "title".into(), old: old.title.clone(), new: ticket.title.clone() }],
);
self.audit_log_repo.create(audit).await?;
}
Creating a New Service Struct
All services follow the same generic-over-repos pattern:
Step 1: Define the struct with type parameters
Each type parameter corresponds to a repository trait. Every service includes
an AuditLogRepository for mutation logging.
#![allow(unused)]
fn main() {
pub struct ProjectService<P, A> {
pub project_repo: P,
pub audit_log_repo: A,
}
}
Step 2: Implement with trait bounds
Bound each type parameter to its repository trait in the impl block:
#![allow(unused)]
fn main() {
impl<P: ProjectRepository, A: AuditLogRepository> ProjectService<P, A> {
pub fn new(project_repo: P, audit_log_repo: A) -> Self {
Self {
project_repo,
audit_log_repo,
}
}
pub async fn create_project(
&self,
actor: &ActorContext,
params: CreateProjectParams,
) -> Result<Project, ServiceError> {
// 1. Validate inputs
// 2. Call repository
// 3. Record audit entry
// 4. Return result
}
}
}
Step 3: Wire into AppService
AppService in alloy-api composes all individual services and their
repository implementations. New services are added as fields on AppService
and constructed in its new() method.
Template
For a new FooService managing Foo resources:
#![allow(unused)]
fn main() {
use crate::models::Foo;
use crate::repos::{AuditLogRepository, FooRepository};
use crate::services::{ActorContext, ServiceError, record_audit};
use crate::models::audit::{AuditAction, FieldChange};
pub struct FooService<F, A> {
pub foo_repo: F,
pub audit_log_repo: A,
}
impl<F: FooRepository, A: AuditLogRepository> FooService<F, A> {
pub fn new(foo_repo: F, audit_log_repo: A) -> Self {
Self { foo_repo, audit_log_repo }
}
pub async fn create_foo(
&self,
actor: &ActorContext,
params: CreateFooParams,
) -> Result<Foo, ServiceError> {
// Validate
if params.name.is_empty() {
return Err(ServiceError::Validation("name is required".into()));
}
// Persist
let foo = self.foo_repo.create(params).await?;
// Audit
let audit = record_audit(
actor, "foo", &foo.id.0.to_string(),
AuditAction::Create, vec![],
);
self.audit_log_repo.create(audit).await?;
Ok(foo)
}
}
}
Existing services
| Service | File | Type Params | Repos Used |
|---|---|---|---|
ProjectService | services/project.rs | <P, A> | ProjectRepository, AuditLogRepository |
TimeEntryService | services/time_entry.rs | <T, A> | TimeEntryRepository, AuditLogRepository |
CommentService | services/comment.rs | <C, A> | CommentRepository, AuditLogRepository |
ApiKeyService | services/api_key.rs | <K, A> | ApiKeyRepository, AuditLogRepository |
AttachmentService | services/attachment.rs | <T, A> | AttachmentRepository, AuditLogRepository |
Ownership Checks Pattern
Most mutations follow a consistent ownership-check pattern: fetch the resource, verify the caller owns it or is Admin+, then proceed.
Standard pattern
#![allow(unused)]
fn main() {
let resource = self.repo.get_by_id(id).await?;
if resource.owner_id != actor.user_id
&& actor.role.level() < OrgRole::Admin.level()
{
return Err(ServiceError::PermissionDenied(
"only the owner or an admin can modify this resource".into(),
));
}
}
Rules:
- Always fetch first — this naturally produces
NotFoundfor missing resources before checking permissions. - Compare the owner field against
actor.user_id. The field name varies by entity:author_id(comments),user_id(time entries),uploaded_by(attachments). - Admin bypass —
actor.role.level() < OrgRole::Admin.level()gates the bypass. Admins and Owners can mutate any resource. - Return
PermissionDenied— notNotFound— so the caller knows the resource exists but they lack access.
Role-only checks
Some operations skip ownership entirely and gate on role alone:
#![allow(unused)]
fn main() {
if actor.role.level() < OrgRole::Admin.level() {
return Err(ServiceError::PermissionDenied(
"only admins can approve time entries".into(),
));
}
}
Used for administrative operations like time entry approval.
Org membership checks
Creation operations verify the caller belongs to the target org:
#![allow(unused)]
fn main() {
if params.org_id != actor.org_id {
return Err(ServiceError::PermissionDenied(
"org membership mismatch".into(),
));
}
}
Cross-org isolation
Repository methods that take org_id as a parameter provide defense-in-depth
against cross-tenant access, supplementing PostgreSQL’s RLS policies:
#![allow(unused)]
fn main() {
let project = self.project_repo.get_by_id(id, actor.org_id).await?;
}
If the project belongs to a different org, the query returns no rows and the
service returns NotFound.
Adding a New Endpoint Checklist
This checklist walks through every file you need to touch when adding a new CRUD endpoint to Alloy. The example assumes a new entity called Widget.
Step 1: Define the Domain Model (crates/alloy-core/src/models.rs)
Add the domain struct and any associated types (ID newtype, create/update params).
#![allow(unused)]
fn main() {
// crates/alloy-core/src/models.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetId(pub Uuid);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Widget {
pub id: WidgetId,
pub org_id: OrgId,
pub project_id: ProjectId,
pub name: String,
pub created_by: UserId,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct CreateWidgetParams {
pub name: String,
pub project_id: ProjectId,
}
#[derive(Debug, Clone)]
pub struct UpdateWidgetParams {
pub name: Option<String>,
}
}
Re-export the new types from crates/alloy-core/src/lib.rs.
Step 2: Define the Repository Trait (crates/alloy-core/src/repos.rs)
Add an #[async_trait] repository trait with standard CRUD methods.
#![allow(unused)]
fn main() {
// crates/alloy-core/src/repos.rs
#[async_trait]
pub trait WidgetRepository: Send + Sync {
async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError>;
async fn get_by_id(&self, id: WidgetId, org_id: OrgId) -> Result<Widget, RepoError>;
async fn list(&self, org_id: OrgId, cursor: Option<String>, limit: i64) -> Result<Page<Widget>, RepoError>;
async fn update(&self, id: WidgetId, org_id: OrgId, params: UpdateWidgetParams) -> Result<Widget, RepoError>;
async fn delete(&self, id: WidgetId, org_id: OrgId) -> Result<(), RepoError>;
}
}
Always include org_id in every method signature for tenant isolation.
Step 3: Write the SQLite Implementation (crates/alloy-api/src/repos/sqlite_widget.rs)
Implement the trait using sqlx::SqlitePool. Follow the existing pattern in
files like sqlite_project.rs or sqlite_label.rs.
- Use
TEXTfor UUIDs and timestamps - Include
AND org_id = ?in every WHERE clause - Register the module in
crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/sqlite_widget.rs
pub struct SqliteWidgetRepo {
pool: SqlitePool,
}
#[async_trait]
impl WidgetRepository for SqliteWidgetRepo {
async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError> {
let id = Uuid::new_v4();
sqlx::query("INSERT INTO widgets (id, org_id, project_id, name, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
// ...bind and execute...
}
// ... other methods with AND org_id = ? in every query ...
}
}
Step 4: Write the PostgreSQL Implementation (crates/alloy-api/src/repos/pg_widget.rs)
Same trait, using sqlx::PgPool. PostgreSQL uses RLS via SET LOCAL app.tenant_id
but also include explicit AND org_id = $N as defense-in-depth.
- Use
TIMESTAMPTZfor timestamps,UUIDfor IDs - Register the module in
crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/pg_widget.rs
pub struct PgWidgetRepo {
pool: PgPool,
}
}
Step 5: Write Database Migrations
Create matching migration files in both directories with the same version
number. Migrations are embedded at compile time via refinery embed_migrations!.
| PostgreSQL | SQLite | |
|---|---|---|
| Path | migrations/postgres/V{N}__create_widgets.sql | migrations/sqlite/V{N}__create_widgets.sql |
| IDs | UUID | TEXT |
| Timestamps | TIMESTAMPTZ | TEXT |
| Strings | TEXT (not VARCHAR) | TEXT |
| Auto-increment | BIGSERIAL | INTEGER PRIMARY KEY |
For PostgreSQL, add an RLS policy:
-- migrations/postgres/V{N}__create_widgets.sql
CREATE TABLE widgets ( ... );
ALTER TABLE widgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY widgets_tenant_isolation ON widgets
USING (org_id = current_setting('app.tenant_id')::UUID);
Step 6: Add the Service Method (crates/alloy-core/src/services/widget.rs)
Create a service struct generic over WidgetRepository and AuditLogRepository.
All business logic — ownership checks, role gates, validation, and audit
logging — goes here. See Service Layer Patterns for the
full pattern.
#![allow(unused)]
fn main() {
// crates/alloy-core/src/services/widget.rs
pub struct WidgetService<W: WidgetRepository, A: AuditLogRepository> {
pub widget_repo: W,
pub audit_repo: A,
}
impl<W: WidgetRepository, A: AuditLogRepository> WidgetService<W, A> {
pub async fn create_widget(&self, actor: &ActorContext, params: CreateWidgetParams) -> Result<Widget, ServiceError> {
actor.require_member()?;
let widget = self.widget_repo.create(actor.org_id, params, actor.user_id).await?;
record_audit(&self.audit_repo, actor, "widget.created", &widget.id.0.to_string()).await;
Ok(widget)
}
// ... get, list, update, delete with ownership/role checks ...
}
}
Register the module in crates/alloy-core/src/services/mod.rs and re-export
from crates/alloy-core/src/lib.rs.
Step 7: Add the HTTP Handler (crates/alloy-api/src/handlers/widget.rs)
Create the Axum handler module. Each handler converts AuthContext to
ActorContext, calls the service, and maps ServiceError to ApiError.
Define routes with role-based grouping:
#![allow(unused)]
fn main() {
// crates/alloy-api/src/handlers/widget.rs
pub fn widget_routes<W: WidgetRepository + 'static, A: AuditLogRepository + 'static>(
state: Arc<WidgetAppState<W, A>>,
) -> Router {
let read_routes = Router::new()
.route("/api/v1/widgets", get(list_widgets::<W, A>))
.route("/api/v1/widgets/{id}", get(get_widget::<W, A>))
.with_state(state.clone())
.layer(middleware::from_fn(require_role(OrgRole::Viewer)));
let write_routes = Router::new()
.route("/api/v1/widgets", post(create_widget::<W, A>))
.route("/api/v1/widgets/{id}", patch(update_widget::<W, A>))
.route("/api/v1/widgets/{id}", delete(delete_widget::<W, A>))
.with_state(state)
.layer(middleware::from_fn(require_role(OrgRole::Member)));
read_routes.merge(write_routes)
}
}
Register the module in crates/alloy-api/src/handlers/mod.rs.
Step 8: Wire the Route into the App (crates/alloy-api/src/lib.rs)
- Add a
widget: Option<Router>field to theRoutersstruct - Include
routers.widgetin theoptional_routersarray insidefn app() - Construct the
WidgetAppStateand callwidget_routes()in your server startup code (e.g.,main.rsorbuild_routers())
Step 9: Add the CLI Command (crates/alloy-cli/src/commands/widget.rs)
Add a Clap subcommand module with create, get, list, update, and delete
subcommands. Follow the pattern in crates/alloy-cli/src/commands/project.rs.
#![allow(unused)]
fn main() {
// crates/alloy-cli/src/commands/widget.rs
#[derive(Subcommand)]
pub enum WidgetCmd {
Create { ... },
Get { id: String },
List { ... },
Update { id: String, ... },
Delete { id: String },
}
}
Register the module in crates/alloy-cli/src/commands/mod.rs and wire it
into the top-level CLI enum.
Step 10: Add the MCP Tool (crates/alloy-mcp/src/lib.rs)
Add #[tool] annotated methods to the MCP server for each operation.
Follow the existing pattern for tools like create_project or list_tickets.
#![allow(unused)]
fn main() {
// crates/alloy-mcp/src/lib.rs
#[tool(description = "Create a new widget")]
async fn create_widget(&self, name: String, project_id: String) -> Result<CallToolResult, McpError> {
// POST /api/v1/widgets with JSON body
}
#[tool(description = "List widgets")]
async fn list_widgets(&self, cursor: Option<String>, limit: Option<i64>) -> Result<CallToolResult, McpError> {
// GET /api/v1/widgets
}
}
Step 11: Add TUI Support (crates/alloy-tui/src/api.rs)
Add API client methods for the new endpoints. Follow the pattern in
api.rs for existing entities.
#![allow(unused)]
fn main() {
// crates/alloy-tui/src/api.rs
pub async fn list_widgets(&self) -> Result<Vec<Widget>> { ... }
pub async fn create_widget(&self, params: CreateWidgetParams) -> Result<Widget> { ... }
}
Wire the new data into the appropriate TUI views if there is a natural place for it (e.g., a project detail screen).
Step 12: Update Documentation and Seed Data
Update all affected documentation in the same commit:
| File | What to update |
|---|---|
docs/api-reference.md | Endpoint docs, field tables, curl examples |
docs/mcp-tools-reference.md | New MCP tool parameters and behavior |
docs/getting-started.md | If the entity is part of the onboarding flow |
docs/guides/*.md | Any concept guide that now covers widgets |
scripts/seed-demo.sh | Add seed data for the new entity |
Quick-Reference File Map
| Step | File(s) |
|---|---|
| Domain model | crates/alloy-core/src/models.rs, crates/alloy-core/src/lib.rs |
| Repo trait | crates/alloy-core/src/repos.rs |
| SQLite repo | crates/alloy-api/src/repos/sqlite_widget.rs, crates/alloy-api/src/repos/mod.rs |
| PostgreSQL repo | crates/alloy-api/src/repos/pg_widget.rs, crates/alloy-api/src/repos/mod.rs |
| Migrations | migrations/postgres/V{N}__*.sql, migrations/sqlite/V{N}__*.sql |
| Service | crates/alloy-core/src/services/widget.rs, crates/alloy-core/src/services/mod.rs |
| Handler + routes | crates/alloy-api/src/handlers/widget.rs, crates/alloy-api/src/handlers/mod.rs |
| App wiring | crates/alloy-api/src/lib.rs (Routers struct + app() fn) |
| CLI | crates/alloy-cli/src/commands/widget.rs, crates/alloy-cli/src/commands/mod.rs |
| MCP | crates/alloy-mcp/src/lib.rs |
| TUI | crates/alloy-tui/src/api.rs |
| Docs | docs/api-reference.md, docs/mcp-tools-reference.md, scripts/seed-demo.sh |
Auth & Authorization Model
Alloy uses a two-layer auth model: an HTTP layer (AuthContext in alloy-api)
handles token validation and scope enforcement, while a service layer
(ActorContext in alloy-core) enforces ownership, role, and org-membership checks
without any framework dependency.
Authentication Flow
Every request carries Authorization: Bearer <token>. The AuthContext Axum
extractor inspects the token prefix to choose a validation path:
Authorization: Bearer <token>
│
├─ Prefix alloy_live_* or alloy_test_* → API key path
│ ├─ SHA256-hash the raw key
│ ├─ Lookup api_keys by key_hash
│ ├─ Check expiry (if set)
│ ├─ Update last_used_at
│ ├─ Fetch user + org membership for role
│ └─ Build AuthContext (scopes + allowed_project_ids from key)
│
└─ Otherwise → JWT path
├─ Verify EdDSA (Ed25519) signature
├─ Validate exp, iss ("alloy"), aud ("alloy-api")
└─ Build AuthContext (scopes = "*", no project restriction)
JWT Details
Config struct: JwtConfig in crates/alloy-core/src/jwt.rs
| Field | Source env var | Default |
|---|---|---|
| Ed25519 private key | ALLOY_JWT_PRIVATE_KEY / _FILE | Auto-generated in dev |
| Ed25519 public key | ALLOY_JWT_PUBLIC_KEY / _FILE | Auto-generated in dev |
| Issuer | ALLOY_JWT_ISSUER | "alloy" |
| Audience | ALLOY_JWT_AUDIENCE | "alloy-api" |
| TTL (seconds) | ALLOY_JWT_TTL_SECONDS | 3600 |
Claims (AlloyClaims):
#![allow(unused)]
fn main() {
pub struct AlloyClaims {
pub sub: Uuid, // User ID
pub org_id: Uuid, // Organization ID
pub email: String,
pub role: OrgRole,
pub iat: i64, // Issued-at (Unix timestamp)
pub exp: i64, // Expiry (Unix timestamp)
pub iss: String, // Issuer
pub aud: String, // Audience
}
}
JWTs always produce scopes = "*" and allowed_project_ids = "" (unrestricted).
API Key Details
Key format: alloy_live_{32 base62 chars} (production) or alloy_test_* (testing).
Storage: Only the SHA256 hex digest (key_hash) is stored. The raw key is
returned once at creation and never stored.
ApiKey model fields:
| Field | Type | Notes |
|---|---|---|
id | ApiKeyId | Primary key |
org_id | OrgId | Owning organization |
user_id | UserId | Creating user |
name | String | Human-readable label |
key_prefix | String | First ~16 chars for identification |
key_hash | String | SHA256 hex digest for lookup |
scopes | String | "*", or comma-separated: "read,write,admin" |
project_ids | String | Comma-separated UUIDs, or empty for all projects |
expires_at | Option<DateTime<Utc>> | Optional expiry |
last_used_at | Option<DateTime<Utc>> | Updated on each use |
AuthContext (HTTP Layer)
Location: crates/alloy-api/src/auth.rs
#![allow(unused)]
fn main() {
pub struct AuthContext {
pub user_id: UserId,
pub org_id: OrgId,
pub email: String,
pub role: OrgRole,
pub scopes: String, // "*" for JWT, comma-separated for API key
pub allowed_project_ids: String, // empty = unrestricted, else comma-separated UUIDs
}
}
AuthContext Methods
| Method | Returns | Logic |
|---|---|---|
has_scope(scope) | bool | "*" grants all; "admin" grants read+write; "write" implies read |
can_access_project(id) | bool | true if unrestricted or scopes="*" or id in list |
require_write() | Result | 403 if !has_scope("write") |
require_project_access(id) | Result | 403 if !can_access_project(id) |
require_owner() | Result | 403 if role != Owner |
require_admin() | Result | 403 if role < Admin |
require_member_or_above() | Result | 403 if role < Member |
require_reporter_or_above() | Result | 403 if role < Reporter |
is_viewer() | bool | Role check |
is_reporter() | bool | Role check |
Axum Extractor
AuthContext implements FromRequestParts. It reads the Authorization header,
delegates to AuthState (which holds JwtConfig + Box<dyn ApiKeyValidator>),
and produces the context or returns 401 Unauthorized.
require_role Middleware
Location: crates/alloy-api/src/auth.rs
#![allow(unused)]
fn main() {
pub fn require_role(minimum_role: OrgRole) -> RequireRoleLayer
}
A Tower layer that wraps any handler. It extracts AuthContext from the request
and checks auth.role.has_at_least(minimum_role). Returns 403 Forbidden if the
role is insufficient.
Usage:
#![allow(unused)]
fn main() {
.route("/admin-only", get(handler))
.layer(require_role(OrgRole::Admin))
}
OrgRole Hierarchy
Location: crates/alloy-core/src/enums.rs
| Role | Level | Grants |
|---|---|---|
Owner | 50 | Everything |
Admin | 40 | All except owner-only actions |
Member | 30 | Standard CRUD on assigned resources |
Reporter | 20 | Create/update time entries, view projects |
Viewer | 10 | Read-only access |
#![allow(unused)]
fn main() {
pub fn has_at_least(&self, minimum: &OrgRole) -> bool {
self.level() >= minimum.level()
}
}
ActorContext (Service Layer)
Location: crates/alloy-core/src/services/mod.rs
A framework-free mirror of AuthContext passed into service methods:
#![allow(unused)]
fn main() {
pub struct ActorContext {
pub user_id: UserId,
pub org_id: OrgId,
pub email: String,
pub role: OrgRole,
pub scopes: String,
pub allowed_project_ids: String,
}
}
ActorContext Methods
| Method | Logic |
|---|---|
has_scope(scope) | Same as AuthContext.has_scope() |
has_project_access(id) | Same as AuthContext.can_access_project() |
has_role(minimum) | Calls role.has_at_least(minimum) |
Conversion from AuthContext
Handlers build an ActorContext when calling into the service layer:
#![allow(unused)]
fn main() {
let actor = ActorContext {
user_id: auth.user_id,
org_id: auth.org_id,
email: auth.email.clone(),
role: auth.role,
scopes: auth.scopes.clone(),
allowed_project_ids: auth.allowed_project_ids.clone(),
};
}
This keeps alloy-core free of Axum dependencies.
Project Scoping (allowed_project_ids)
API keys can be restricted to specific projects. The flow:
- At key creation: User specifies
project_ids(comma-separated UUIDs). - At validation:
allowed_project_idsis copied intoAuthContext. - At request time:
enforce_scopesmiddleware extracts the project ID from the URL path and callscan_access_project(). - In handlers: List endpoints post-filter results through
auth.can_access_project(&entry.project_id)for entries spanning multiple projects.
Access logic:
- Empty
allowed_project_ids→ unrestricted (all projects) scopes == "*"→ unrestricted- Otherwise → project ID must appear in the comma-separated list
JWTs always get unrestricted project access.
Ownership Checks in Services
Services enforce two kinds of authorization beyond role checks:
1. Org Membership
Every mutation verifies the resource belongs to the actor’s org:
#![allow(unused)]
fn main() {
if resource.org_id != actor.org_id {
return Err(ServiceError::PermissionDenied("org membership mismatch".into()));
}
}
2. Owner-or-Admin Pattern
For user-owned resources (time entries, API keys), the standard pattern is:
#![allow(unused)]
fn main() {
// Fetch the resource (produces NotFound naturally if missing)
let entry = repo.get_by_id(id).await?;
// Owner can always act on their own resource; Admin+ can act on anyone's
if entry.user_id != actor.user_id && !actor.has_role(&OrgRole::Admin) {
return Err(ServiceError::PermissionDenied(
"only the owner or an admin can modify this resource".into(),
));
}
}
ServiceError Variants
#![allow(unused)]
fn main() {
pub enum ServiceError {
NotFound(String),
AlreadyExists(String),
ConstraintViolation(String),
PermissionDenied(String),
Validation(String),
Internal(String),
}
}
ServiceError maps to ApiError in alloy-api:
| ServiceError | HTTP Status |
|---|---|
NotFound | 404 |
AlreadyExists | 409 |
ConstraintViolation | 422 |
PermissionDenied | 403 |
Validation | 400 |
Internal | 500 |
Tenant Isolation: SQLite vs PostgreSQL
PostgreSQL (Multi-Tenant with RLS)
PostgreSQL uses Row Level Security to enforce tenant isolation at the database level. Every query runs inside a transaction that sets the tenant context:
BEGIN;
SET LOCAL app.tenant_id = '<org_id>';
-- All subsequent queries in this transaction are filtered by RLS
Helper function (created in V9__enable_rls.sql):
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS UUID AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::UUID;
$$ LANGUAGE sql STABLE;
RLS policies per table:
| Table | Policy |
|---|---|
organizations | id = current_tenant_id() |
projects | org_id = current_tenant_id() |
tickets | project_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id()) |
teams | org_id = current_tenant_id() |
org_memberships | org_id = current_tenant_id() |
Code path (crates/alloy-api/src/tenant.rs):
#![allow(unused)]
fn main() {
tokio::task_local! { static CURRENT_TENANT: OrgId; }
pub async fn begin_tenant_tx(pool: &PgPool) -> Result<Transaction<'_, Postgres>>
pub fn current_tenant_id() -> Option<OrgId>
pub async fn with_tenant_scope<F: Future>(org_id: OrgId, fut: F) -> F::Output
}
The tenant_context_middleware runs before handlers and sets the task-local
from the authenticated org_id.
SQLite (Single-Tenant)
SQLite deployments are single-tenant by design (one org per database file). There
is no RLS. The V9__enable_rls.sql migration for SQLite is a no-op.
However, SQLite repositories still include explicit AND org_id = ? filters in
queries as defense-in-depth. This prevents data leaks if the application is ever
misconfigured with multiple orgs in a single SQLite database.
Comparison
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Isolation mechanism | RLS policies | Explicit AND org_id = ? |
| Multi-tenant | Yes | No (single-tenant) |
| Tenant context | SET LOCAL app.tenant_id | Not needed |
| Migration V9 | Creates RLS policies + function | No-op |
| Defense-in-depth | RLS + application checks | Application checks only |
Scope Semantics
| Scope | Grants | Typical use |
|---|---|---|
"*" | Everything | JWT auth (always) |
"admin" | read + write + admin actions | Full API key |
"write" | read + write | CI/CD automation keys |
"read" | read only | Dashboard/reporting keys |
Scope inheritance: admin ⊃ write ⊃ read.
Middleware Stack (Request Order)
1. CORS / HSTS headers
2. AuthContext extraction (JWT or API key validation)
3. enforce_scopes (write scope + project access for API keys)
4. tenant_context_middleware (sets task-local org_id for PG RLS)
5. require_role (on routes that need it)
6. Handler → ActorContext → Service layer
Testing Strategy & Verify Pipeline
Alloy uses a layered testing strategy that combines Rust unit/integration tests
with shell-based end-to-end scripts. Everything runs through a single entry
point: ./scripts/verify.sh.
The Verify Pipeline
verify.sh runs 10 sequential steps. If any step fails, the pipeline stops.
Steps 5–9 share a single temporary SQLite server that is started automatically
and torn down on exit.
| Step | Command / Script | What It Checks |
|---|---|---|
| 1 | cargo fmt --all -- --check | Code formatting (rustfmt) |
| 2 | cargo check --workspace --all-targets | Compilation (all crates, all targets) |
| 3 | cargo clippy --workspace --all-targets -- -D warnings | Lint warnings treated as errors |
| 4 | cargo test --workspace --all-features | All Rust unit and integration tests |
| 5 | seed-demo.sh | API integration — creates demo data and asserts read-back |
| 6 | test-mcp.sh | MCP server — JSON-RPC over stdio against live data |
| 7 | test-permissions.sh | RBAC — all 5 roles across every endpoint |
| 8 | test-tui-api.sh | TUI API paths — smoke-tests every route the TUI uses |
| 9 | test-cli.sh | CLI end-to-end — every CLI command with API cross-validation |
| 10 | verify-docs.sh | Documentation — curl examples match live API responses |
Running Individual Steps
# Format
cargo fmt --all -- --check
# Compile check
cargo check --workspace --all-targets
# Clippy
cargo clippy --workspace --all-targets -- -D warnings
# Rust tests only
cargo test --workspace --all-features
# Any integration script standalone (starts its own server if BASE_URL unset)
bash scripts/test-permissions.sh
# Doc verification (always starts its own server)
bash scripts/verify-docs.sh
Shared Server for Steps 5–9
Steps 5–9 reuse a single ephemeral server to avoid repeated compilation. The pipeline:
- Creates a temporary SQLite database file.
- Picks a random free port via Python.
- Starts
alloy servewithALLOY_AUTO_MIGRATE=true,ALLOY_REGISTRATION=open, and high rate limits. - Waits up to 30 seconds for
/healthto return 200. - Runs
seed-demo.sh, which writes a token file consumed by later steps. - Passes the token, org ID, and user ID as environment variables to subsequent scripts.
- Cleans up the server process and temp files on exit (via
trap).
seed-demo.sh — API Integration Test
seed-demo.sh is both a data seeder and an integration test. It creates a
complete set of demo data and asserts every creation via read-back.
What it creates: A user (demo@alloy.dev / demodemo1), an org
(“Acme Corp”), a project (“Demo Project”), 4 labels, a sprint, 6 tickets,
comments, and time entries.
How it works:
- Helper functions
post(),get(),del()wrap curl with auth headers and exit on non-2xx responses. assert_eq()compares expected vs actual values; any mismatch is fatal.expect_status()verifies specific HTTP status codes (used for negative tests like duplicate detection).- After each POST, a GET reads the resource back and asserts field values match.
- On success, writes
{token, org_id, user_id}to$ALLOY_TOKEN_FILEfor downstream scripts.
Environment variables:
| Variable | Default | Description |
|---|---|---|
BASE_URL | http://localhost:3000 | Server address |
ALLOY_TOKEN_FILE | (none) | Path to write auth context JSON for later scripts |
test-mcp.sh — MCP Integration Test
Tests the MCP server binary (alloy-mcp) by sending JSON-RPC messages over
stdio and validating responses.
How it works:
- Launches
alloy-mcpas a subprocess withBASE_URL,TOKEN,SEED_ORG_ID, andSEED_USER_IDenvironment variables. - Sends JSON-RPC
tools/callrequests for each MCP tool (list projects, create ticket, etc.). - Parses the JSON-RPC response and validates fields using
assert_eq,assert_not_empty, andassert_containshelpers. - Includes HTTP helpers (
post_json,get_json) for API key creation and cross-validation against the REST API.
Required environment variables: BASE_URL, TOKEN, SEED_ORG_ID,
SEED_USER_ID.
test-permissions.sh — Role-Based Access Control Tests
Tests all 5 roles (Owner, Admin, Member, Reporter, Viewer) across every endpoint, verifying that each role gets the expected HTTP status code.
How it works:
- Setup: Registers 5 users, creates an org, invites each user with a
different role, and logs each in to get role-specific tokens
(
TOKEN_OWNER,TOKEN_ADMIN,TOKEN_MEMBER,TOKEN_REPORTER,TOKEN_VIEWER). - Tests: For each endpoint and HTTP method, calls
check()with every role’s token and asserts the expected status code (200/201 for allowed, 403 for forbidden). - Self-contained: If
BASE_URLis not set, starts its own temporary server.
Key helper — check():
check METHOD URL BODY EXPECTED_STATUS LABEL TOKEN
Fires an HTTP request and compares the response status code to the expected
value. Increments PASS_COUNT or FAIL_COUNT and records failure details.
test-tui-api.sh — TUI API Smoke Test
Verifies that every HTTP path used in alloy-tui/src/api.rs actually works
against a live server with seed data.
How it works:
- The
check()helper sends a request and asserts a 2xx response code. - Walks through every API call the TUI makes:
/health,/api/v1/auth/me, project listing, ticket listing, ticket detail, comments, sprints, labels, workflows, and mutations (create/update ticket, add comment). - Captures IDs from responses to use in subsequent requests (e.g., gets a project ID, then lists its tickets).
Required environment variables: BASE_URL, TOKEN, SEED_ORG_ID.
Why this exists: The TUI compiles against api.rs types, not against the
server’s router. A path mismatch (e.g., /api/v1/tickets vs
/api/v1/projects/:id/tickets) compiles fine but fails at runtime. This script
catches those mismatches early.
test-cli.sh — CLI End-to-End Test
Tests every CLI command by running the real alloy binary and validating output.
How it works:
- Uses
cargo run --quiet --bin alloy --with--api-urland--format jsonflags to execute CLI commands programmatically. - Creates a fake
$HOMEdirectory so CLI credential storage doesn’t conflict with real user credentials. - Preserves
$RUSTUP_HOMEand$CARGO_HOMEso cargo still works under the fake home. run_cli()runs a command, increments pass/fail counters, and returns stdout.assert_eq()validates specific field values from JSON output.api_get()cross-validates CLI operations by reading back via the REST API.- Tests the full lifecycle:
auth login, project/ticket/sprint CRUD, comments, labels, time entries.
Required environment variables: BASE_URL, TOKEN, SEED_ORG_ID.
verify-docs.sh — Documentation Curl Validation
Extracts curl examples from all Markdown documentation files and validates them against a live server.
How it works:
- Starts its own temporary SQLite server and seeds it via
seed-demo.sh. - Collects all
.mdfiles fromdocs/,docs/tutorials/, anddocs/guides/. - A Python script extracts “bash/sh + json” block pairs from each file:
- A
```bashblock containingcurlfollowed immediately by a```jsonblock is treated as a testable pair. - Script-like blocks (shebangs, loops, function definitions) are skipped.
- A
- For each pair:
- Substitutes environment variables (
$BASE_URL,$TOKEN,$PROJECT_ID, etc.). - Runs the curl command and captures the response.
- Validates the response JSON against the expected pattern.
- Substitutes environment variables (
- Wildcard matching:
"..."in expected JSON values means the field must exist but any value is accepted. Exact values are compared strictly. - Auto-capture: The validator automatically captures IDs, tokens, and other values from responses to use as variables in subsequent curl commands within the same file.
Writing testable curl examples:
```bash
curl -s -X GET "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN"
```
```json
{
"items": [
{
"id": "...",
"name": "Acme Corp"
}
]
}
```
Rules:
- The
```jsonblock must immediately follow the```bashblock (blank lines between are OK). - Use
"..."for dynamic values (IDs, timestamps). - Use exact values for fields you want to assert.
- Available variables:
$BASE_URL,$TOKEN,$USER_ID,$ORG_ID,$PROJECT_ID,$TICKET_ID,$SPRINT_ID,$LABEL_ID,$COMMENT_ID,$WORKFLOW_ID,$TIME_ENTRY_ID,$API_KEY_ID,$API_KEY,$WEBHOOK_ID,$LABOR_RATE_ID,$INVITE_ID,$INVITE_CODE.
Adding a New Test
Rust Unit/Integration Test
- Add the test in the same file as the code it tests, or in a
tests/module. - Use
#[sqlx::test]for PostgreSQL integration tests gated behind#[cfg(feature = "postgres-tests")]. - Use in-memory SQLite for fast tests that don’t need PostgreSQL-specific features.
- Run
cargo test --workspace --all-featuresto verify.
New API Endpoint Test
When adding a new endpoint, add coverage in multiple layers:
- seed-demo.sh — Add a creation call and read-back assertion for the new resource.
- test-permissions.sh — Add
check()calls for all 5 roles against the new endpoint. - test-tui-api.sh — If the TUI uses the endpoint, add a
check()call. - test-cli.sh — If there’s a CLI command, add a
run_cli()call with assertions. - test-mcp.sh — If there’s an MCP tool, add a JSON-RPC test.
- Documentation — Add curl + json example pairs in the relevant docs so
verify-docs.shvalidates them.
New Documentation Example
Follow the curl + json block pair format described in the verify-docs.sh
section above. Run bash scripts/verify-docs.sh to validate your examples
before committing.
Migration Guide
This document covers how database migrations work in Alloy, including naming conventions, the dual-backend requirement, type differences between PostgreSQL and SQLite, how refinery embeds migrations into the binary, keeping version numbers aligned, and testing migrations.
Overview
Alloy supports two database backends: PostgreSQL (multi-tenant with RLS)
and SQLite (single-tenant, single-file). Every migration must exist in both
backends. Migrations are embedded into the compiled binary via refinery’s
embed_migrations! macro — there are no external SQL files at runtime.
Directory Layout
migrations/
├── postgres/
│ ├── V1__create_schema_info.sql
│ ├── V2__create_organizations.sql
│ ├── ...
│ └── V32__add_time_entry_indexes.sql
└── sqlite/
├── V1__create_schema_info.sql
├── V2__create_organizations.sql
├── ...
└── V32__add_time_entry_indexes.sql
Each directory contains the same set of version numbers. The Rust code in
crates/alloy-api/src/db.rs embeds them:
#![allow(unused)]
fn main() {
mod pg_migrations {
refinery::embed_migrations!("../../migrations/postgres");
}
mod sqlite_migrations {
refinery::embed_migrations!("../../migrations/sqlite");
}
}
Naming Convention
Migration files follow refinery’s naming scheme:
V{number}__{description}.sql
- V — prefix (uppercase)
- {number} — sequential integer, no zero-padding (1, 2, … 32)
- __ — double underscore separator
- {description} — snake_case description of the change
- .sql — file extension
Examples:
V7__create_tickets.sqlV17__add_project_capitalization.sqlV31__add_ticket_indexes.sql
To find the next version number, check the highest existing number:
ls migrations/postgres/ | sort -t_ -k1 -V | tail -1
Dual-Backend Requirement
Every migration version must exist in both migrations/postgres/ and
migrations/sqlite/. Even when a migration is backend-specific (like RLS
policies), the other backend needs a matching no-op file to keep version
numbers aligned.
Example — V9 enables RLS in PostgreSQL:
migrations/postgres/V9__enable_rls.sql — Full RLS setup with policies,
roles, and grants.
migrations/sqlite/V9__enable_rls.sql — No-op placeholder:
-- No-op: RLS is PostgreSQL-only. This migration exists to keep
-- SQLite and PostgreSQL version numbers aligned.
If you forget the counterpart file, the backends will diverge and future migrations will fail with version-mismatch errors.
Type Differences
Write SQL that works on both backends where possible. When that isn’t possible, write backend-specific SQL in each file.
| Concept | PostgreSQL | SQLite |
|---|---|---|
| Strings | TEXT | TEXT |
| Variable-length strings | Use TEXT (not VARCHAR) | TEXT |
| Primary keys | TEXT PRIMARY KEY (UUIDs as text) | TEXT PRIMARY KEY |
| Auto-increment integers | SERIAL or BIGSERIAL | INTEGER PRIMARY KEY AUTOINCREMENT |
| UUIDs | UUID (native type) | TEXT |
| Timestamps | TIMESTAMPTZ NOT NULL DEFAULT NOW() | TEXT NOT NULL DEFAULT (datetime('now')) |
| Booleans | BOOLEAN NOT NULL DEFAULT false | INTEGER NOT NULL DEFAULT 0 |
| JSON | JSONB | TEXT |
Rules of thumb:
- Always use
TEXTinstead ofVARCHAR— works on both backends. - Use
TEXTfor UUIDs on both backends (PostgreSQL can cast, SQLite needs it). - Timestamps differ:
TIMESTAMPTZ+NOW()for PG,TEXT+datetime('now')for SQLite. - Booleans differ:
BOOLEANfor PG,INTEGERfor SQLite. - Avoid
SERIAL— use application-generated UUIDs as primary keys instead.
PostgreSQL-Only Features
Some migrations contain PostgreSQL-specific SQL that has no SQLite equivalent:
- Row Level Security (RLS):
ALTER TABLE ... ENABLE ROW LEVEL SECURITY,CREATE POLICY,current_setting(). SQLite uses explicitAND org_id = ?in application queries instead. - Roles and grants:
CREATE ROLE,GRANT,ALTER DEFAULT PRIVILEGES. - PL/pgSQL functions:
CREATE OR REPLACE FUNCTION ... LANGUAGE sql. DO $$blocks: Anonymous code blocks for conditional DDL.
For all of these, create a no-op SQLite migration with a comment explaining why.
How Refinery embed_migrations! Works
Refinery’s embed_migrations! macro reads the SQL files at compile time
and embeds them as string constants in the binary. This means:
- No runtime file access — the binary contains all migrations. No need to ship SQL files alongside the executable.
- Compile-time validation — if a migration file is malformed or missing, the build fails.
- Version tracking — refinery creates a
refinery_schema_historytable in the database to track which migrations have been applied. - Forward-only — refinery does not support down migrations. To undo a change, write a new migration that reverses it.
The migration runner is triggered in crates/alloy-api/src/db.rs via the
Database::migrate() method. In production, auto-migration on startup is
controlled by ALLOY_AUTO_MIGRATE=true.
Writing a New Migration
Step 1: Determine the next version number
ls migrations/postgres/ | sort -t_ -k1 -V | tail -1
# If the last file is V32__add_time_entry_indexes.sql, your next is V33
Step 2: Create both files
touch migrations/postgres/V33__your_description.sql
touch migrations/sqlite/V33__your_description.sql
Step 3: Write the SQL
Write the PostgreSQL version first, then adapt for SQLite. If the SQL is
identical (e.g., ALTER TABLE ... ADD COLUMN ... TEXT), the files can be the
same. If not, write backend-specific SQL in each.
Example — adding a column (identical on both):
ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
Example — adding an index (identical on both):
CREATE INDEX idx_tickets_assignee ON tickets(assignee_id);
Example — PostgreSQL-specific (adding RLS policy):
migrations/postgres/V33__add_rls_for_new_table.sql:
ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON new_table
FOR ALL USING (org_id = current_tenant_id());
migrations/sqlite/V33__add_rls_for_new_table.sql:
-- No-op: RLS is PostgreSQL-only.
Step 4: Verify the build
cargo check --workspace --all-targets
This compiles the migrations into the binary. If your SQL files are not in the right location or have naming issues, this step will catch it.
Step 5: Test the migration
Run the full test suite to ensure the migration applies cleanly:
cargo test --workspace --all-features
SQLite tests use in-memory databases and run all migrations from scratch on every test, so any migration issue will surface immediately.
For PostgreSQL tests (gated behind #[cfg(feature = "postgres-tests")]), the
migration runner also applies from scratch against a test database.
Keeping Version Numbers Aligned
The version numbers in migrations/postgres/ and migrations/sqlite/ must
always match one-to-one. Refinery tracks applied versions per database — if
PostgreSQL is on V32 but SQLite only has files up to V31, SQLite tests will
fail when they encounter V32 in the PostgreSQL directory (or vice versa if you
run them against the wrong directory).
Checklist before committing a migration:
- Both
migrations/postgres/V{N}__name.sqlandmigrations/sqlite/V{N}__name.sqlexist - Version number
Nis the next sequential integer (no gaps, no duplicates) - File names match the
V{N}__{description}.sqlpattern (double underscore) -
cargo check --workspacepasses (migrations compile into binary) -
cargo test --workspacepasses (migrations apply cleanly)
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Missing SQLite counterpart | PG tests pass, SQLite tests fail with version error | Add no-op SQLite file |
Using VARCHAR instead of TEXT | Works on PG, fails on strict SQLite parsers | Always use TEXT |
Using NOW() in SQLite | Syntax error | Use datetime('now') for SQLite |
Using BOOLEAN in SQLite | Column created but stores 0/1 as text | Use INTEGER for SQLite |
| Gap in version numbers | refinery refuses to run | Renumber to fill the gap |
| Duplicate version number | Compile error from refinery | Use next available number |
Single underscore V1_name | refinery ignores the file | Use double underscore V1__name |
| Modifying an applied migration | Checksum mismatch error in production | Write a new migration instead |
Seed Data Migrations
Some migrations insert data rather than alter schema. For example,
V28__seed_default_workflows.sql inserts default workflow states. These follow
the same dual-backend rules — the SQL syntax for INSERT is usually identical
across both backends, but watch for type differences in the values (e.g., UUID
format, timestamp format).
MCP Server Internals
The Alloy MCP server (crates/alloy-mcp/) is an MCP-protocol server that
exposes Alloy’s project management capabilities to AI assistants. It runs as an
independent binary, communicates with the Alloy API over HTTP (via reqwest),
and speaks the MCP protocol over stdio.
AlloyMcp Struct
The core server handler lives in crates/alloy-mcp/src/lib.rs:
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct AlloyMcp {
config: McpConfig,
auth_context: McpAuthContext,
tool_router: ToolRouter<Self>,
}
}
| Field | Type | Purpose |
|---|---|---|
config | McpConfig | API base URL (api_url) and bearer token (api_token) |
auth_context | McpAuthContext | Validated user identity (user_id, org_id, email, role) |
tool_router | ToolRouter<Self> | Macro-generated tool registry |
McpConfig is loaded from environment variables at startup:
#![allow(unused)]
fn main() {
impl McpConfig {
pub fn from_env() -> Result<Self, String> {
let api_url = std::env::var("ALLOY_API_URL")?;
let api_token = std::env::var("ALLOY_API_TOKEN")?;
Ok(Self { api_url, api_token })
}
}
}
Required env vars:
ALLOY_API_URL— e.g.,http://localhost:3000ALLOY_API_TOKEN— must start withalloy_live_oralloy_test_
Tool Router Composition
Tools are organized into domain-specific modules, each annotated with the
#[tool_router] macro from rmcp. The AlloyMcp::new() constructor composes
them additively using the + operator:
#![allow(unused)]
fn main() {
impl AlloyMcp {
pub fn new(config: McpConfig, auth_context: McpAuthContext) -> Self {
Self {
config,
auth_context,
tool_router: Self::tool_router() // ping, whoami
+ Self::ticket_tools() // create, list, get, update, delete tickets
+ Self::project_tools() // create, list, get, update, delete projects
+ Self::sprint_tools() // create, list, get, update, delete sprints
+ Self::comment_tools() // add, list, delete comments
+ Self::label_tools() // create, list, assign, remove labels
+ Self::report_tools() // capitalization, burndown reports
+ Self::admin_tools(), // invite user, list members, manage API keys
}
}
}
}
Each module lives in crates/alloy-mcp/src/tools/ and exports a router via the
#[tool_router] attribute:
#![allow(unused)]
fn main() {
#[tool_router(router = ticket_tools, vis = "pub(crate)")]
impl AlloyMcp {
#[tool(description = "Create a new ticket in a project")]
async fn create_ticket(
&self,
params: Parameters<CreateTicketParams>,
) -> Result<CallToolResult, McpError> {
let p = params.0;
let body = serde_json::json!({"title": p.title, "reporter_id": self.auth_context.user_id});
let url = format!("{}/api/v1/projects/{}/tickets", self.config.api_url, p.project_id);
self.api_post(&url, &body).await
}
}
}
The #[tool] macro generates the MCP tool schema (name, description, input
schema) from the function signature and doc attributes. Parameters<T> is an
rmcp wrapper that deserializes the MCP arguments object into a typed struct.
How Tools Call the API via reqwest
AlloyMcp implements five HTTP helper methods that all tools share:
#![allow(unused)]
fn main() {
async fn api_get(&self, url: &str) -> Result<CallToolResult, McpError>;
async fn api_post(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_put(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_patch(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_delete(&self, url: &str) -> Result<CallToolResult, McpError>;
}
Every request:
- Creates a
reqwest::Client - Sets the
Authorization: Bearer <api_token>header - Sends the request
- Passes the response to
handle_response()for status-based error mapping
Tool implementations follow a consistent pattern:
- Extract typed parameters from
params.0 - Build a JSON body (for mutations)
- Construct the URL using
self.config.api_urland path segments - Call the appropriate
api_*method - Return the
CallToolResult(success returns the raw JSON body as text content)
How Prompts Work
Prompts are MCP slash commands that provide structured instructions to AI
assistants for common workflows. They live in crates/alloy-mcp/src/prompts.rs.
Listing Prompts
prompt_list() returns a ListPromptsResult describing all available prompts
with their names, descriptions, and accepted arguments:
| Prompt | Purpose | Arguments |
|---|---|---|
assign | Assign ticket to team member | ticket_id, project_key |
create-ticket | Create new ticket | project_key, title |
comment | Add comment to ticket | ticket_id |
invite | Invite user to org | email |
log-work | Interactive time logging | project_key |
move | Transition ticket status | ticket_id, status |
new-project | Create new project | name, key |
my-work | Summarize assigned tickets | — |
ping | Check connectivity | — |
plan-sprint | Review backlog, plan sprint | project_key |
project-summary | High-level project overview | project_key |
report | Generate project status report | project_key |
search | Search tickets | query |
sprint-status | Fetch active sprint burndowns | project_key |
standup | Generate daily standup summary | project_key |
Dispatching Prompts
prompt_dispatch() routes the prompt name to the handler function:
#![allow(unused)]
fn main() {
pub(crate) fn prompt_dispatch(
&self,
request: GetPromptRequestParams,
) -> Result<GetPromptResult, McpError> {
match request.name.as_str() {
"assign" => self.get_prompt_assign(request),
"create-ticket" => self.get_prompt_create_ticket(request),
// ... remaining prompts ...
_ => Err(McpError { code: ErrorCode::INVALID_REQUEST, ... }),
}
}
}
Prompt Handler Pattern
Each handler extracts optional arguments, builds conditional instructions, and
returns a GetPromptResult with PromptMessage entries:
#![allow(unused)]
fn main() {
fn get_prompt_create_ticket(&self, request: GetPromptRequestParams) -> Result<GetPromptResult, McpError> {
let args = request.arguments.unwrap_or_default();
let project_key = args.get("project_key").and_then(|v| v.as_str());
let mut instructions = String::from("You are helping the user create a new ticket...\n\n");
// Pre-fill already-provided arguments
if let Some(key) = project_key {
write!(instructions, "The user already specified: {key}. Do not ask again.\n\n").ok();
}
let mut result = GetPromptResult::new(vec![PromptMessage::new(
PromptMessageRole::User,
PromptMessageContent::text(instructions),
)]);
result.description = Some("Gather information and create a new ticket in Alloy".to_string());
Ok(result)
}
}
Arguments that the user already provided are injected into the instructions so the AI doesn’t ask for them again. Missing arguments trigger conversational prompts.
Stdio vs HTTP Transport
The MCP server currently supports stdio transport only. The entry point is
run_stdio():
#![allow(unused)]
fn main() {
pub async fn run_stdio(config: McpConfig) -> Result<(), Box<dyn std::error::Error>> {
// Logs go to stderr (stdout is the MCP protocol channel)
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
// Validate token before accepting MCP requests
let auth_context = auth::validate_api_token(&config.api_url, &config.api_token).await?;
// Start MCP server over stdin/stdout
let service = AlloyMcp::new(config, auth_context)
.serve(stdio())
.await?;
service.waiting().await?;
Ok(())
}
}
In stdio mode:
- stdin/stdout carry JSON-RPC MCP protocol messages
- stderr receives tracing/log output (to avoid corrupting the protocol stream)
- Authentication happens once at startup — the token is validated and the
resulting
McpAuthContextis reused for all subsequent tool calls
HTTP transport is mentioned in code comments as a future possibility. In that
mode, McpAuthContext would be resolved per-request from the incoming Bearer
token rather than once at startup. This is not yet implemented.
Binary Entry Point
The main.rs is minimal — load config from env, run stdio, exit:
#[tokio::main]
async fn main() -> ExitCode {
let config = match McpConfig::from_env() {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {e}");
eprintln!("Required environment variables:");
eprintln!(" ALLOY_API_URL — Alloy API base URL");
eprintln!(" ALLOY_API_TOKEN — API key for authenticating");
return ExitCode::FAILURE;
}
};
if let Err(e) = alloy_mcp::run_stdio(config).await {
eprintln!("Fatal: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
Auth Validation on Startup
Before the MCP server begins accepting requests, it validates the API token by
calling the Alloy API’s /api/v1/auth/me endpoint. This lives in
crates/alloy-mcp/src/auth.rs:
#![allow(unused)]
fn main() {
pub async fn validate_api_token(
api_url: &str,
token: &str,
) -> Result<McpAuthContext, McpAuthError> {
// 1. Validate prefix
if !token.starts_with("alloy_live_") && !token.starts_with("alloy_test_") {
return Err(McpAuthError::InvalidTokenFormat);
}
// 2. Call API to validate and resolve identity
let client = reqwest::Client::new();
let response = client
.get(format!("{api_url}/api/v1/auth/me"))
.bearer_auth(token)
.send()
.await
.map_err(|e| McpAuthError::ApiError(e.to_string()))?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(McpAuthError::Unauthorized);
}
response.json::<McpAuthContext>().await
.map_err(|e| McpAuthError::ApiError(e.to_string()))
}
}
The validation sequence:
- Prefix check — token must start with
alloy_live_(production) oralloy_test_(testing). Rejects malformed tokens before making any network call. - API call —
GET /api/v1/auth/mewith the token as a Bearer header. The Alloy API validates the token hash against the database and returns the associated user context. - Identity resolution — the JSON response is deserialized into
McpAuthContextwithuser_id,org_id,email, androlefields.
If validation fails, the server exits immediately with an error — no MCP requests are ever processed with an invalid token.
McpAuthError Variants
| Variant | Cause |
|---|---|
MissingToken | ALLOY_API_TOKEN env var not set |
InvalidTokenFormat | Token doesn’t start with alloy_live_ or alloy_test_ |
Unauthorized | API returned 401 — token is invalid or revoked |
ApiError(String) | Network failure or unexpected response |
Error Mapping
HTTP responses from the Alloy API are translated into MCP errors via
handle_response():
#![allow(unused)]
fn main() {
async fn handle_response(&self, response: reqwest::Response) -> Result<CallToolResult, McpError> {
let status = response.status();
let body = response.text().await
.map_err(|e| api_error(&format!("Failed to read response: {e}")))?;
if status.is_success() {
Ok(CallToolResult::success(vec![Content::text(body)]))
} else {
let message = if let Ok(err) = serde_json::from_str::<ApiErrorResponse>(&body) {
err.error
} else {
body
};
let actionable = match status.as_u16() {
400 => format!("Bad request: {message}. Check your parameters..."),
401 => format!("Authentication failed: {message}. Your API token..."),
403 => format!("Permission denied: {message}. You may not have..."),
404 => format!("Not found: {message}. Verify the ID..."),
422 => format!("Validation error: {message}. Check field values..."),
_ => format!("API error (HTTP {status}): {message}"),
};
Err(api_error(&actionable))
}
}
}
| HTTP Status | MCP Error Message Prefix | Guidance |
|---|---|---|
| 200–299 | (success) | Raw JSON body returned as text content |
| 400 | Bad request | Check parameters and required fields |
| 401 | Authentication failed | Token may be invalid or expired |
| 403 | Permission denied | Insufficient role or project scope |
| 404 | Not found | Verify entity ID exists |
| 422 | Validation error | Check field values and constraints |
| Other | API error (HTTP N) | Generic fallback |
All errors are wrapped with McpError { code: ErrorCode::INTERNAL_ERROR, ... }.
The message text is designed to be actionable — it tells the AI assistant (or
user) what to check, not just what went wrong.
The helper function api_error() constructs the McpError:
#![allow(unused)]
fn main() {
fn api_error(message: &str) -> McpError {
McpError {
code: ErrorCode::INTERNAL_ERROR,
message: message.to_string().into(),
data: None,
}
}
}
ServerHandler Implementation
AlloyMcp implements rmcp::ServerHandler, which is the MCP protocol trait.
The #[tool_handler] macro wires the tool_router into the handler
automatically:
#![allow(unused)]
fn main() {
#[tool_handler]
impl ServerHandler for AlloyMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.build(),
)
.with_server_info(Implementation::new("alloy-mcp", env!("CARGO_PKG_VERSION")))
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions("Alloy project-management MCP server. ...")
}
}
}
Advertised capabilities:
- Tools — CRUD operations on all entities (tickets, projects, sprints, etc.)
- Resources — read-only data access via URI templates (
alloy://project/{key},alloy://ticket/{id}, etc.) - Prompts — 15 slash commands for guided AI workflows
Resources
MCP resources provide read-only access to Alloy data via URI patterns:
| URI Pattern | Description |
|---|---|
alloy://projects | List all projects |
alloy://project/{key} | Get project by key |
alloy://ticket/{id} | Get ticket by UUID |
alloy://sprint/{id}/board | Sprint board view |
alloy://user/{id}/assigned | Tickets assigned to user |
read_resource() parses the URI, calls the appropriate API endpoint, and
returns the JSON response as resource content.
Dependencies
[dependencies]
reqwest = { workspace = true }
rmcp = { version = "1.2", features = ["server", "macros", "transport-io"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
The key external dependency is rmcp 1.2 (the official Rust MCP SDK) with:
server— server-side protocol implementationmacros—#[tool_router],#[tool_handler],#[tool]procedural macrostransport-io— stdio transport support