Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_FILE and ALLOY_JWT_PUBLIC_KEY_FILE pointing 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:

RoleDescription
OwnerFull control. Assigned automatically to the org creator.
AdminManage workflows, teams, invites, and delete resources.
MemberCreate and update projects, tickets, sprints, and time entries.
ReporterCreate tickets and comments. Limited to assigned projects.
ViewerRead-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

Use Cases

Playbooks

Tutorials

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:

FlagEnv VarDefaultDescription
--format <json|table|plain>ALLOY_FORMATauto-detectOutput format
--api-url <URL>ALLOY_API_URLhttp://localhost:3000API server URL

Output Formats

The --format flag controls how results are displayed:

FormatDescription
jsonMachine-readable JSON (pretty-printed)
tableHuman-readable columns with headers
plainMinimal 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>]
FlagDescription
--formatOutput 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 VarDefaultDescription
ALLOY_DATABASE_URLsqlite://alloy.dbDatabase 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>]
FlagEnv VarDefaultDescription
--db <URL>ALLOY_DATABASE_URLsqlite://alloy.dbDatabase URL
--port <PORT>PORT3000Listen 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>
FlagDescription
--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>]
FlagDefaultDescription
--name <NAME>(required)Human-readable key name
--scopes <SCOPES>read,writeComma-separated: read, write, admin
--projects <IDS>all projectsComma-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 resources
  • write — Create, update, and delete resources
  • admin — 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>
FlagDescription
--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>]
FlagDescription
--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>]
FlagDefaultDescription
--org-id <ORG_ID>(required)Organization UUID
--cursor <CURSOR>Pagination cursor from previous response
--limit <LIMIT>20Results 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:

FormatExampleResolution
UUID550e8400-...Used directly
KEY-NUMBERBACK-42Resolved via API (/api/v1/tickets/resolve?ref=BACK-42)
Bare number42Requires 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>]
FlagDefaultDescription
--project <ID>credentials projectProject UUID (prompted interactively if omitted)
--title <TITLE>Ticket title (prompted interactively if omitted)
--description <DESC>Detailed description
--priority <PRIORITY>mediumnone, low, medium, high, urgent
--status <STATUS>backlogbacklog, todo, in_progress, in_review, done, cancelled
--assignee-id <UUID>Assignee user UUID (fuzzy selector in interactive mode)
--reporter-id <UUID>logged-in userReporter 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>]
FlagDefaultDescription
--project <ID>credentials projectProject 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>20Results 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.

FlagDescription
--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.

FlagDescription
--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>]
FlagDescription
--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>
FlagDescription
--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>
FlagDescription
--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>...
FlagDescription
--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>
FlagDescription
--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>
FlagDescription
--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

Search for entities by tag key-value pair.

alloy tag search --org <ORG_ID> --key <KEY> --value <VALUE> [--cursor <CURSOR>] [--limit <LIMIT>]
FlagDefaultDescription
--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>20Results 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>]
FlagDescription
--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]
FlagDescription
--cascadeCascade-delete all dependents (members, projects)
--dry-runPreview 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>
FlagDescription
--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>
FlagDescription
--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>
FlagDescription
--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>]
FlagDefaultDescription
--period <YYYY-MM>(required)Reporting month
--group-by <GROUP>Group by team or user
--include-usersfalseInclude individual user breakdowns
--include-budgetfalseInclude 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>]
FlagDefaultDescription
--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>]
FlagDefaultDescription
--org <ORG_ID>stored orgOrganization UUID (defaults to org from login)
--project <KEY>Filter to a single project by key
--export-format <FORMAT>jsonExport format: json or sqlite
-o, --output <FILE>alloy-export.dbOutput 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/FlagDefaultDescription
[FILE]stdinPath to JSON export file (reads stdin if omitted)
--org <ORG_ID>stored orgOrganization UUID (defaults to org from login)
--from <SOURCE>alloySource format: alloy (native), jira (Jira export), or linear (Linear export)
--dry-runPreview 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 conceptAlloy conceptNotes
ProjectsProjectsKey and name preserved
IssuesTicketsNumber extracted from issue key
Issue typesLabelsMapped as type:<IssueType> labels
PrioritiesPrioritiesBlocker/Highest→Urgent, High/Major→High, etc.
StatusesWorkflow statusesGrouped into a “Jira Import” workflow
VersionsSprintsReleased versions become Completed sprints
ComponentsLabelsMapped to labels with a warning
CommentsCommentsAuthor and body preserved
WorklogsTime entriesDuration converted from seconds to minutes
LabelsLabelsPreserved as-is
LinksSkipped with a warning
Custom fieldsSkipped with a warning
ResolutionsLogged as warnings (status preserved)

Linear import mapping:

Linear conceptAlloy conceptNotes
TeamsProjectsKey and name preserved
IssuesTicketsNumber extracted from identifier
PrioritiesPriorities1=Urgent, 2=High, 3=Medium, 4=Low, 0=None
StatesWorkflow statusesGrouped into a “Linear Import” workflow
CyclesSprintsCompleted cycles become Completed sprints
LabelsLabelsColor preserved from Linear
CommentsCommentsAuthor and body preserved
UsersUsersEmail preserved; display name used
ProjectsSkipped with a warning (teams used instead)
RelationsSkipped with a warning
EstimatesSkipped with a warning
Sub-issuesTicketsImported 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/FlagDescription
<SPRINT_ID>Sprint UUID
--formatOutput 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/FlagDescription
<SPRINT_ID>Sprint UUID
--formatOutput 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: planningactivecompleted.

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>]
FlagDescription
--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: draftsubmittedapproved.

time log

alloy time log [--user-id <UUID>] [--ticket <TICKET_REF>] [--project <PROJECT_ID>] [--date <DATE>] [--duration <MINUTES>] [--description <DESC>] [--activity-type <TYPE>]
FlagDefaultDescription
--user-id <UUID>logged-in userUser UUID
--ticket <REF>Ticket reference (prompted if missing)
--project <ID>from ticketProject UUID
--date <DATE>todayDate 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>]
FlagDefaultDescription
--period <YYYY-MM>(required)Reporting month
--output <csv|json>csvReport 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>]
FlagDefaultDescription
--org-id <ORG_ID>(required)Organization UUID
--email <EMAIL>Invitee email (omit for open invite link)
--role <ROLE>MemberOwner, 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>
FlagDescription
--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]
ArgumentDescription
SUBCOMMANDOptional 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>
ArgumentValuesDescription
SHELLbash, zsh, fishShell 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

VariableDefaultDescription
ALLOY_BASE_URLhttp://localhost:3000Alloy API server URL
ALLOY_ORG_ID(auto-discovered)Organization ID override

Auto-Discover Behavior

On startup the TUI automatically discovers your organization:

  1. If ALLOY_ORG_ID is set, that value is used directly.
  2. Otherwise, once connected to the API, the TUI calls GET /api/v1/auth/me and extracts org_id from the response.
  3. After org_id is 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.

ModeColorHow to enterHow to exitPurpose
NormalBlueEsc from any modeNavigate, select, trigger actions
InsertGreeni from NormalEscEdit text fields
CommandYellow: from NormalEsc or EnterRun commands like :q, :sort
SearchMagenta/ from NormalEscReal-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.

KeyAction
j / DownMove cursor down
k / UpMove cursor up
ggJump to first ticket
GJump to last ticket
EnterOpen ticket detail
TabCycle project filter (All → each project → All)
/Enter Search mode
nCreate new ticket (see Ticket Creation)
lManage labels on selected ticket
tLog time against selected ticket
dDelete selected ticket
DCascade delete (ticket + dependents)
?Show help overlay
qQuit

Ticket Detail

Full view of a single ticket: status, priority, assignee, reporter, sprint, timestamps, description (word-wrapped), and comments.

KeyAction
j / DownScroll down
k / UpScroll up
ggScroll to top
GScroll to bottom
nCreate new ticket
lManage labels on this ticket
tLog time against this ticket
dDelete this ticket
DCascade delete (ticket + dependents)
Esc / qReturn to ticket list

Sprint Board

Horizontal kanban board showing tickets grouped by status columns.

KeyAction
h / LeftMove to previous column
l / RightMove to next column
TabNext column
Shift+TabPrevious column
j / DownMove down within column
k / UpMove up within column
ggJump to top of column
GJump to bottom of column
EnterOpen ticket detail
bToggle burndown chart
LManage labels on selected ticket
tLog time against selected ticket
dDelete selected ticket
DCascade delete (ticket + dependents)
Esc / qReturn 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.

KeyActionPopup behavior
sChange statusSelect from: Backlog, Todo, InProgress, InReview, Done, Cancelled
aAssign ticketSelect from known assignees, or Unassign
cAdd commentType comment text, Enter to submit
pChange prioritySelect from: Urgent, High, Medium, Low, None
nCreate ticketEnter title and priority, Enter to submit
lManage labelsToggle labels on/off for a ticket (L on sprint board)
tLog timeEnter duration (minutes) and activity type
dDelete ticketConfirm deletion (Cancel selected by default)
DCascade deletePreview dependents, then confirm

Inside a popup:

KeyAction
j / DownMove selection down
k / UpMove selection up
TabSwitch between fields (multi-field popups)
EnterConfirm selection/submit
EscCancel 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:

FieldInput typeNotes
TitleTextRequired — popup will not submit if empty
PrioritySelectorUrgent, 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 ticket
  • label-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:

FieldInput typeNotes
DurationNumberMinutes (1–1440). Only digits accepted
Activity TypeSelectorCoding, 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:

  1. A dry-run request previews what will be deleted (e.g., comment count).
  2. A confirmation popup shows “Yes, delete ticket + N comments” and “Cancel”.
  3. 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).

CommandDescription
:sprint <id>Open sprint board for the given sprint ID
:sprint listList 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.

CommandDescription
:q / :quitExit the application
:filter status=XFilter tickets by status (e.g., :filter status=Todo)
:filterClear status filter
:sort field:dirSort tickets (e.g., :sort priority:desc)
:sortClear sort, return to default order
:sprint <id>Open sprint board for given sprint ID
:sprint listList 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
:projectClear project filter (show all)

Sort fields: priority, status, title, assignee Sort directions: asc (default), desc

Command mode helpers:

KeyAction
TabAuto-complete command name
UpPrevious command from history
DownNext command from history
BackspaceDelete character (empty → Normal mode)
EscCancel, return to Normal mode

Press / to enter Search mode. Type to filter tickets in real time.

  • Matches against ticket key, title, and status (case-insensitive)
  • Press Esc to stop searching (matches remain highlighted)
  • Press n for next match, N for previous match (in Normal mode)
  • Filtering respects active project and status filters

Complete Keybinding Reference

Normal Mode — Global

KeyAction
:Enter Command mode
/Enter Search mode
?Show help overlay
qQuit application
EscReturn to Normal mode / close popup

Normal Mode — Navigation

KeyAction
j / DownMove down / scroll
k / UpMove up / scroll
ggJump to top
GJump to bottom
h / LeftPrevious column (sprint board)
l / RightNext column (sprint board)
TabNext column / cycle project filter
Shift+TabPrevious column (sprint board)
EnterOpen ticket detail

Normal Mode — Actions

KeyActionViews
sChange statusList, Detail, Board
aAssign ticketList, Detail, Board
cAdd commentList, Detail, Board
pChange priorityList, Detail, Board
nCreate ticketList, Detail
lManage labelsList, Detail
LManage labelsSprint Board
tLog timeList, Detail, Board
dDelete ticketList, Detail, Board
DCascade deleteList, Detail, Board
bToggle burndownSprint Board

Search Mode

KeyAction
anyAppended to search query
EscExit search (matches highlighted)
nNext match (after exiting search)
NPrevious match
KeyAction
j / DownMove selection down
k / UpMove selection up
TabSwitch field (multi-field popups)
EnterConfirm / submit
EscCancel / close
BackspaceDelete 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

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

Guide: Teams, Roles, and Permissions

Every organization member has one of five roles, listed from most to least privileged:

RoleDescription
OwnerFull control. Created automatically for the org creator.
AdminManage workflows, teams, invites, delete resources.
MemberCreate and update projects, tickets, sprints, time entries.
ReporterCreate tickets and comments. Limited to assigned projects.
ViewerRead-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.

OperationMinimum Role
Orgs
Create / update orgOwner
Invites
Create / revoke inviteAdmin
List invitesAny authenticated
Workflows
Create / update / delete workflowAdmin
Teams
Delete teamAdmin
Projects
Create / update projectMember
Delete projectAdmin
Project Members
Add / remove project memberMember
List project membersAny authenticated
Tickets
Create ticketReporter (must be assigned to the project)
Update / transition ticketMember
Delete ticketAdmin
Comments
Create commentReporter
Update / delete commentAuthor or Admin
Sprints
Create / update / start / complete sprintMember
Delete sprintAdmin
Time Entries
Create / update / delete / submit time entryMember
Approve time entryAdmin
Labor Rates
All labor rate operationsAdmin

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": []
  }
}
StatusMeaning
401Missing or invalid token
403Insufficient permissions (see Roles and Permissions)
404Resource not found
409Conflict — resource has dependents or duplicate key (see Delete Guards)
422Validation error (details array lists each field)
500Internal 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:

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all dependents recursively
dry_runbooleanfalseWith 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
}
ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page

Auth

Register

Create a new user account.

POST /api/v1/auth/register — no auth required

FieldTypeRequiredDescription
emailstringyesValid email address
passwordstringyesMinimum 8 characters
display_namestringyes1–255 characters
invite_codestringnoRequired 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

FieldTypeRequiredDescription
emailstringyesEmail address
passwordstringyesPassword
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

FieldTypeRequiredDescription
refresh_tokenstringyesRefresh 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

FieldTypeRequiredDescription
refresh_tokenstringyesToken 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

FieldTypeRequiredDescription
namestringyesKey name, 1–255 characters
scopesstring[]no["read", "write"] (default) or ["admin"]
project_idsstring[]noRestrict 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

FieldTypeRequiredDescription
emailstringyesAdmin email
passwordstringyesMinimum 8 characters
org_namestringyesOrganization name, 1–255 characters
org_slugstringyesOrganization 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

FieldTypeRequiredDescription
namestringyes1–255 characters
slugstringyes1–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

FieldTypeRequiredDescription
user_idstringyesUUID of user to add
rolestringnoowner, 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

FieldTypeRequiredDescription
emailstringnoEmail to invite
rolestringnoRole to assign (default: member)
created_bystringno(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

FieldTypeRequiredDescription
org_idstringyesOrganization UUID
keystringyesShort project key, 1–10 chars (e.g. PROJ)
namestringyes1–255 characters
descriptionstringnoProject description
team_idstringnoTeam UUID
budget_centsintegernoBudget amount in cents
budget_periodstringnoBudget 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 ParamTypeRequiredDescription
org_idstringyesOrganization UUID
cursorstringnoPagination cursor
limitintegernoPage 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

ParameterTypeRequiredDescription
org_idstring (query)noOrganization 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

FieldTypeRequiredDescription
namestringnoNew name
descriptionstring | nullnoNew description or null to clear
team_idstring | nullnoNew team or null to unset
capitalization_typestring | nullnoCapitalization type
development_phasestring | nullnoDevelopment phase
cost_center_idstring | nullnoCost center ID
amortization_monthsinteger | nullnoAmortization months
budget_centsinteger | nullnoBudget amount in cents, or null to clear
budget_periodstring | nullnoMonthly, 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.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all tickets, sprints, and comments
dry_runbooleanfalseWith 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

Guide: Teams, Roles, and Permissions

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

FieldTypeRequiredDescription
user_idstringyesUser 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

FieldTypeRequiredDescription
titlestringyes1–500 characters
descriptionstringnoTicket description
statusstringnoBacklog (default), Todo, InProgress, InReview, Done, Cancelled
prioritystringnoNone (default), Low, Medium, High, Critical
assignee_idstringnoUser UUID
reporter_idstringno(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 ParamTypeRequiredDescription
statusstringnoFilter by status
prioritystringnoFilter by priority
assignee_idstringnoFilter by assignee
cursorstringnoPagination cursor
limitintegernoPage 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 ParamTypeRequiredDescription
refstringyesUUID, KEY-NUMBER, or bare number
project_idstringnoRequired when using bare number
org_idstringnoOrganization 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

FieldTypeRequiredDescription
titlestringnoNew title
descriptionstring | nullnoNew description or null
statusstringnoNew status
prioritystringnoNew priority
assignee_idstring | nullnoNew 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.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all comments
dry_runbooleanfalseWith 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

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to update
titlestringnoNew title for all tickets
descriptionstring|nullnoNew description (null to clear)
statusstringnoNew status
prioritystringnoNew priority
assignee_idstring|nullnoAssignee UUID (null to clear)
sprint_idstring|nullnoSprint 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

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to delete
cascadebooleannoIf true, delete dependents (comments) too. Default: false
dry_runbooleannoIf 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

FieldTypeRequiredDescription
to_statusstringyesTarget 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

FieldTypeRequiredDescription
bodystringyesComment text (min 1 char)
author_idstringno(deprecated, ignored) Derived from auth token
parent_comment_idstringnoParent 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

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number 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

FieldTypeRequiredDescription
bodystringyesUpdated 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

Guide: Labels, Tags, and Organization

Create Label

POST /api/v1/orgs/{org_id}/labels — requires auth

FieldTypeRequiredDescription
namestringyesLabel name (min 1 char)
colorstringyesHex 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

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number 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

FieldTypeRequiredDescription
namestringnoNew label name
colorstringnoNew 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

FieldTypeRequiredDescription
ticket_idsstring[]yesArray 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.

ParameterTypeDefaultDescription
cascadebooleanfalseRemove all ticket-label associations
dry_runbooleanfalseWith 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

FieldTypeRequiredDescription
label_idsstring[]yesLabel 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

FieldTypeRequiredDescription
namestringyesSprint name (min 1 char)
goalstringnoSprint goal
start_datestringyesISO 8601 date
end_datestringyesISO 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

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number 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

FieldTypeRequiredDescription
namestringnoNew sprint name
goalstringnoNew sprint goal (pass null to clear)
start_datestringnoNew start date
end_datestringnoNew 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

FieldTypeRequiredDescription
ticket_idsstring[]yesArray 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.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all tickets in the sprint
dry_runbooleanfalseWith 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:

ModeBehavior
noneAny transition is allowed regardless of workflow rules (default)
warnInvalid transitions are allowed but the response includes a warning field
strictInvalid 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

FieldTypeRequiredDescription
namestringyesWorkflow name (min 1 char)
statusesobject[]yesStatus definitions
statuses[].namestringyesStatus name
statuses[].categorystringyestodo, in_progress, done, or cancelled
transitionsobject[]yesAllowed transitions
transitions[].fromstringyesSource status name
transitions[].tostringyesTarget status name
enforcementstringnonone (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

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number 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

FieldTypeRequiredDescription
namestringnoNew workflow name
statusesobject[]noReplacement status definitions
transitionsobject[]noReplacement transition rules
enforcementstringnonone, 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 cascade or dry_run parameters. 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

FieldTypeRequiredDescription
to_statusstringyesTarget 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

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to transition
to_statusstringyesTarget 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

FieldTypeRequiredDescription
user_idstringyesUser UUID
ticket_idstringyesTicket UUID
project_idstringyesProject UUID
datestringyesISO 8601 date
duration_minutesintegeryesDuration in minutes
descriptionstringnoEntry description
activity_typestringyesCoding, 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 ParamTypeRequiredDescription
user_idstringnoFilter by user
project_idstringnoFilter by project
datestringnoFilter by date
cursorstringnoPagination cursor
limitintegernoPage 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 ParamTypeRequiredDescription
project_idstringnoFilter by project
datestringnoFilter by date
cursorstringnoPagination cursor
limitintegernoPage 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

FieldTypeRequiredDescription
datestringnoNew date
duration_minutesintegernoNew duration
descriptionstring | nullnoNew description or null
activity_typestringnoNew 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)

FieldTypeRequiredDescription
user_idstringyesUser UUID
org_idstringyesOrganization UUID
loaded_rate_centsintegeryesLoaded rate in cents per hour
effective_datestringyesISO 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 ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage 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

Guide: Labels, Tags, and Organization

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 ParamTypeRequiredDescription
org_idstringyesOrganization UUID
entity_typestringyesOne of: project, ticket, user, team, time_entry
entity_idstringyesEntity UUID
Body FieldTypeRequiredDescription
tagsarrayyesArray 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 ParamTypeRequiredDescription
keystringyesTag key to search for
valuestringyesTag value to match
cursorstringnoPagination cursor
limitintegernoPage 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 ParamTypeRequiredDescription
periodstringyesReport period in YYYY-MM format
group_bystringnoGroup results by team or user
include_usersbooleannoInclude per-user breakdown in project entries
include_budgetbooleannoInclude budget/ROI fields on each project
team_idstringnoFilter to a specific team UUID
user_idstringnoFilter to a specific user UUID
cost_center_idstringnoFilter to a specific cost center
activity_typestringnoFilter to a specific activity type (e.g. Coding)
tagstringnoFilter 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 ParamTypeRequiredDescription
periodstringyesReport period in YYYY-MM format
formatstringnoExport format (default: csv)
team_idstringnoFilter to a specific team UUID
user_idstringnoFilter to a specific user UUID
cost_center_idstringnoFilter to a specific cost center
activity_typestringnoFilter to a specific activity type
tagstringnoFilter 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 ParamTypeRequiredDescription
org_idstringyesOrganization UUID
projectstringnoFilter to a single project by key (e.g. PROJ)
formatstringnoExport 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.

FieldTypeRequiredDescription
versionintegeryesExport format version (must be 1)
sourcestringyesSource identifier (e.g. "alloy")
exported_atstringyesISO 8601 timestamp of when the export was created
projectsarrayyesArray of project objects
labelsarrayyesArray of label objects
workflowsarrayyesArray of workflow objects
usersarrayyesArray 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

FieldTypeRequiredDescription
urlstringyesDestination URL (must be valid URL)
event_typesarrayyesEvents 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 secret field is only returned on creation. Store it securely — it is used to verify webhook signatures via the X-Alloy-Signature header.

List Webhooks

GET /api/v1/orgs/{org_id}/webhooks — requires auth

Query ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage 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 ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage 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 ParamTypeRequiredDescription
org_idstringyesOrganization 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 ParamTypeRequiredDescription
codestringyesAuthorization code from IdP
statestringyesState 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

FieldTypeRequiredDescription
userNamestringyesEmail address
displayNamestringnoDisplay 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

FieldTypeRequiredDescription
displayNamestringyesGroup name
membersarraynoArray 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_SECRET environment variable. Configure a GitHub webhook pointing to this endpoint with the pull_request event.

Receive GitHub Webhook

POST /api/v1/integrations/github/webhook — no auth required (signature verified)

HeaderRequiredDescription
X-Hub-Signature-256yesHMAC-SHA256 signature of the payload
X-GitHub-EventyesEvent 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 optionally ALLOY_SLACK_DEFAULT_CHANNEL environment variables.

Receive Slack Command

POST /api/v1/integrations/slack/commands — no auth required (signature verified)

HeaderRequiredDescription
X-Slack-Request-TimestampyesRequest timestamp
X-Slack-SignatureyesHMAC-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

FieldTypeRequiredDescription
notification_typestringyesassignment, mention, status_change, comment_sync
ticket_idstringyesTicket UUID
channel_idstringnoSlack channel ID
user_slack_idstringnoSlack user ID for DM
messagestringnoCustom message text
old_statusstringnoPrevious status (for status_change)
new_statusstringnoNew status (for status_change)
actor_namestringnoWho triggered the action
thread_tsstringnoSlack 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.

FieldTypeRequiredDescription
ticket_idstringyesTicket UUID
channel_idstringyesSlack channel ID
thread_tsstringyesSlack 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

FieldTypeRequiredDescription
filenamestringyesFile name
content_typestringyesMIME type
size_bytesintegeryesFile 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

FieldTypeRequiredDescription
filenamestringyesFile name
content_typestringyesMIME type
size_bytesintegeryesFile size
s3_keystringyesS3 key from presign response
uploaded_bystringyesUser 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?

CriteriaSQLitePostgreSQL
Best forSolo developer, small team, evaluationTeams, multi-tenant, production
SetupZero — single file, auto-createdRequires running PostgreSQL server
Multi-tenancySingle-tenant onlyFull multi-tenant with RLS
ConcurrencyLimited (single writer)High concurrency
BackupCopy the .db filepg_dump / replication
Migration pathExport via API, re-import into PGN/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:

  1. Stand up a PostgreSQL instance and start Alloy pointed at it (migrations run automatically)
  2. Export your data from the SQLite instance using the API (list projects, tickets, etc.)
  3. Import into PostgreSQL via the API
  4. Switch your ALLOY_DATABASE_URL to 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

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-attachments bucket 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_id via SET LOCAL in 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:

  1. Automatically request a TLS certificate from Let’s Encrypt for api.example.com
  2. Cache the certificate on disk (default: ./acme_cache)
  3. Serve HTTPS on the configured port (default 3000)

CLI flags

FlagDescription
--tls-domainDomain to provision a certificate for. Omit for plain HTTP.
--tls-contactEmail address for Let’s Encrypt notifications (recommended).
--tls-stagingUse the Let’s Encrypt staging environment (for testing — avoids rate limits).
--tls-cache-dirDirectory to cache certificates. Default: ./acme_cache.

Environment variables (TLS)

VariableDefaultDescription
ALLOY_TLS_DOMAINDomain for automatic TLS (alternative to --tls-domain).
ALLOY_TLS_CONTACTContact email for Let’s Encrypt (alternative to --tls-contact).
ALLOY_TLS_STAGINGfalseUse staging environment (alternative to --tls-staging).
ALLOY_TLS_CACHE_DIR./acme_cacheCertificate 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

MountPurpose
/dataSQLite database file (default ALLOY_DATABASE_URL=sqlite:///data/alloy.db)
/secretsJWT key files (if using file-based keys)

Environment Variables Reference

Core

VariableDefaultDescription
ALLOY_DATABASE_URLsqlite://alloy.dbDatabase connection string. Use sqlite://path for SQLite or postgres://... for PostgreSQL.
ALLOY_AUTO_MIGRATEtrueRun database migrations automatically on startup. Set to false to skip.
PORT3000TCP port the HTTP server listens on.
ALLOY_REGISTRATIONopenRegistration mode: open (anyone can register) or invite (invite-only).

Authentication (JWT)

VariableDefaultDescription
ALLOY_JWT_PRIVATE_KEY(required)Ed25519/RSA private key (PEM string) for signing JWTs.
ALLOY_JWT_PRIVATE_KEY_FILEPath to private key file (alternative to inline).
ALLOY_JWT_PUBLIC_KEY(required)Corresponding public key (PEM string) for verifying JWTs.
ALLOY_JWT_PUBLIC_KEY_FILEPath to public key file (alternative to inline).
ALLOY_JWT_ISSUERalloyJWT iss claim value.
ALLOY_JWT_AUDIENCEalloy-apiJWT aud claim value.
ALLOY_JWT_TTL_SECONDS3600JWT token lifetime in seconds.

S3 / Object Storage (Attachments)

VariableDefaultDescription
ALLOY_S3_ENDPOINTS3-compatible endpoint URL (e.g., http://localhost:9000 for MinIO).
ALLOY_S3_BUCKETalloy-attachmentsBucket name for file attachments.
ALLOY_S3_REGIONus-east-1S3 region.
ALLOY_S3_ACCESS_KEY_IDS3 access key.
ALLOY_S3_SECRET_ACCESS_KEYS3 secret key.

Security

VariableDefaultDescription
ALLOY_HTTPSfalseSet to true to mark cookies as Secure (requires TLS termination).
ALLOY_CORS_ORIGINS(permissive)Comma-separated list of allowed CORS origins.

Rate Limiting

VariableDefaultDescription
ALLOY_RATE_LIMIT_GLOBALGlobal requests per minute limit.
ALLOY_RATE_LIMIT_AUTHAuthenticated endpoint requests per minute.
ALLOY_RATE_LIMIT_LOGINLogin endpoint requests per minute.

Slack Integration

VariableDefaultDescription
ALLOY_SLACK_SIGNING_SECRETSlack app signing secret for verifying webhook requests.
ALLOY_SLACK_BOT_TOKENSlack bot OAuth token (starts with xoxb-).
ALLOY_SLACK_NOTIFICATION_CHANNELgeneralDefault Slack channel for notifications.
ALLOY_SLACK_DEFAULT_USER_IDFallback Slack user ID when user mapping is unavailable.

GitHub Integration

VariableDefaultDescription
ALLOY_GITHUB_WEBHOOK_SECRETSecret for verifying GitHub webhook payloads (HMAC-SHA256).

SCIM Provisioning

VariableDefaultDescription
ALLOY_SCIM_BEARER_TOKENBearer token for authenticating SCIM provisioning requests.
ALLOY_SCIM_ORG_IDOrganization ID to provision SCIM users/groups into.

MCP Server

VariableDefaultDescription
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

VariableDefaultDescription
ALLOY_BASE_URLhttp://localhost:3000Alloy 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

  1. Create a Slack App at api.slack.com/apps
  2. Enable Event Subscriptions and set the request URL to https://your-alloy-host/api/v1/integrations/slack/events
  3. Enable Slash Commands (e.g., /alloy) with the request URL https://your-alloy-host/api/v1/integrations/slack/commands
  4. Under OAuth & Permissions, add bot scopes: chat:write, commands
  5. Install the app to your workspace and copy the Bot User OAuth Token
  6. 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

  1. Create a GitHub App or use repository webhooks
  2. Set the webhook URL to https://your-alloy-host/api/v1/integrations/github/webhook
  3. Select events: push, pull_request, issues, etc.
  4. 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:

  1. 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
  2. Note the Client ID, Client Secret, and Issuer URL (e.g., https://your-org.okta.com/oauth2/default)
  3. 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"
      }'
    
  4. 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 TABLE permissions
  • If a migration was partially applied, check the refinery_schema_history table and fix manually
  • Set ALLOY_AUTO_MIGRATE=false to 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) and ALLOY_JWT_PUBLIC_KEY (or _FILE) are required
  • For API key auth, ensure the key starts with alloy_live_ or alloy_test_
  • Check ALLOY_JWT_ISSUER and ALLOY_JWT_AUDIENCE match between token issuer and verifier
  • If using SSO, confirm the identity provider’s issuer_url is 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 postgres and 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_SECRET matches your Slack app’s signing secret
  • Ensure webhook URLs are publicly reachable (use ngrok for local development)
  • Check that bot scopes include chat:write and commands

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:

ModeUse caseHow it works
stdioClaude Desktop, Claude Code, local devBinary communicates over stdin/stdout (JSON-RPC)
HTTPRemote servers, shared environmentsTCP 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-mcp is not on your $PATH, use the full path in the command field (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"
      }
    }
  }
}
FieldDescription
commandPath to the alloy-mcp binary. Use the full path if it is not on your $PATH.
ALLOY_API_URLBase URL of your running Alloy API (e.g., http://localhost:3000).
ALLOY_API_TOKENThe 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 3001 in the example above)
  • Authenticates each request via a Bearer token in the Authorization header
  • 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

SymptomCauseFix
“connection refused”Alloy API not runningStart the API server first (alloy serve or cargo run -p alloy-api -- serve)
“connection refused” on port 3001HTTP-mode MCP server not runningStart it with alloy-mcp --http 0.0.0.0:3001
Timeout or hangingAPI URL wrong or unreachableCheck ALLOY_API_URL — it must match the running server’s address and port

Authentication errors

SymptomCauseFix
“invalid API token”Bad or missing ALLOY_API_TOKENRegenerate the key; ensure it starts with alloy_live_ or alloy_test_
“unauthorized” / 401Token expired or revokedCreate a new API key and update your config
“forbidden” / 403Token missing required scopesRecreate the key with "scopes": ["read", "write"]

Tools not appearing

SymptomCauseFix
No Alloy tools listedConfig file not loadedRestart Claude Desktop / Claude Code after saving the config
No Alloy tools listedJSON syntax error in configValidate the JSON (e.g., jq . < .claude/mcp.json) — a trailing comma or missing quote will silently fail
No Alloy tools listedWrong config file locationClaude 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 failBinary not foundUse the full absolute path in the command field, or verify alloy-mcp is on your $PATH with which alloy-mcp
Tools listed but calls failBinary is stale / outdatedRebuild 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

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:

CategoryTools
Connectivityping, whoami
Ticketscreate_ticket, get_ticket, search_tickets, update_ticket, transition_ticket, batch_transition, batch_update_tickets, assign_ticket, get_my_tickets, delete_ticket
Commentsadd_comment, list_comments, update_comment, delete_comment
Label Managementcreate_label, list_labels, add_ticket_label, remove_ticket_label, get_label, update_label, delete_label
Tag Managementset_tags, get_tags, delete_tag, search_by_tag
Project Managementcreate_project, list_projects, get_project, update_project, delete_project
Sprint Lifecyclecreate_sprint, list_sprints, update_sprint, start_sprint, complete_sprint, get_sprint_burndown, get_sprint, delete_sprint
Team Managementcreate_team, list_teams, delete_team
Workflow Managementcreate_workflow, list_workflows, update_workflow, get_workflow, delete_workflow
Projects & Sprintsget_project_summary
Organization & Accesslist_members, create_invite, list_invites, add_project_member, remove_project_member, list_project_members, create_api_key, list_api_keys, delete_api_key
Activityget_ticket_activity
Time Trackinglog_time, get_time_entry, update_time_entry, delete_time_entry, list_time_entries
Finance & Reportingget_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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
titlestringyesTicket title (1–500 characters)
descriptionstringnoTicket description (markdown supported)
statusstringnoInitial status (defaults to Backlog). Values are defined by the project’s workflow; see Status Values
prioritystringnoPriority level (defaults to None)
assignee_idstringnoUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
statusstringnoFilter by status (workflow-defined; see Status Values)
prioritystringnoFilter by priority
assignee_idstringnoFilter by assignee UUID
limitintegernoMax results per page (1–100, default 50)
cursorstringnoPagination 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
titlestringnoNew title (1–500 characters)
descriptionstringnoNew description (markdown supported, or null to clear)
statusstringnoNew status (workflow-defined; see Status Values)
prioritystringnoNew priority
assignee_idstringnoUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
to_statusstringyesTarget 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:

NameTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to transition
to_statusstringyesTarget 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:

NameTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to update
titlestringnoNew title for all tickets (1–500 characters)
descriptionstringnoNew description (markdown supported, or null to clear)
statusstringnoNew status for all tickets
prioritystringnoNew priority: None, Low, Medium, High, Urgent
assignee_idstringnoUUID of user to assign, or null to unassign
sprint_idstringnoUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
assignee_idstringnoUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
statusstringnoFilter by status (workflow-defined; see Status Values)
limitintegernoMax results per page (1–100, default 50)
cursorstringnoPagination 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket to delete
cascadebooleannoForce-delete the ticket and all its comments (default false)
dry_runbooleannoPreview 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
bodystringyesComment body (markdown supported)
parent_comment_idstringnoUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
limitintegernoMax results per page (1–100, default 20)
cursorstringnoPagination 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:

NameTypeRequiredDescription
comment_idstringyesUUID of the comment to update
bodystringyesNew 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:

NameTypeRequiredDescription
comment_idstringyesUUID 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:

NameTypeRequiredDescription
namestringyesThe label name (e.g. “bug”, “feature”, “urgent”)
colorstringyesHex 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:

NameTypeRequiredDescription
limitintegernoMaximum results to return (1–100). Defaults to 20
cursorstringnoCursor 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket to add the label to
label_idstringyesUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket to remove the label from
label_idstringyesUUID 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:

NameTypeRequiredDescription
label_idstringyesUUID 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:

NameTypeRequiredDescription
label_idstringyesUUID of the label to update
namestringnoNew label name
colorstringnoNew 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:

NameTypeRequiredDescription
label_idstringyesUUID of the label to delete
cascadebooleannoForce-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:

NameTypeRequiredDescription
entity_typestringyesThe entity type to tag. One of: project, ticket, user, team, time_entry
entity_idstringyesUUID of the entity to tag
tagsarrayyesArray 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:

NameTypeRequiredDescription
entity_typestringyesThe entity type. One of: project, ticket, user, team, time_entry
entity_idstringyesUUID 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:

NameTypeRequiredDescription
entity_typestringyesThe entity type. One of: project, ticket, user, team, time_entry
entity_idstringyesUUID of the entity to delete a tag from
tag_keystringyesThe 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:

NameTypeRequiredDescription
keystringyesThe tag key to search for (e.g. “environment”)
valuestringyesThe tag value to search for (e.g. “production”)
limitintegernoMaximum results to return (1–100, default 20)
cursorstringnoCursor 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:

NameTypeRequiredDescription
keystringyesUnique short key for the project (1–10 characters, e.g. ALLOY)
namestringyesProject name (1–255 characters)
descriptionstringnoProject description
team_idstringnoUUID of the team that owns this project
budget_centsintegernoBudget amount in cents (e.g. 100000 = $1,000.00)
budget_periodstringnoBudget 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:

NameTypeRequiredDescription
limitintegernoMax results per page (1–100, default 20)
cursorstringnoPagination 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:

NameTypeRequiredDescription
project_idstringyesUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project to update
namestringnoNew project name (1–255 characters)
descriptionstring | nullnoNew description, or null to clear
team_idstring | nullnoUUID of team that owns this project, or null to clear
budget_centsinteger | nullnoBudget in cents (e.g. 100000 = $1,000.00), or null to clear
budget_periodstring | nullnoMonthly, Quarterly, Yearly, Fixed, or null to clear
capitalization_typestring | nullnoCapEx or OpEx, or null to clear
development_phasestring | nullnoPlanning, Development, Maintenance, Sunset, or null to clear
cost_center_idstring | nullnoCost center ID string, or null to clear
amortization_monthsinteger | nullnoAmortization 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project to delete
cascadebooleannoIf true, delete all dependent tickets, sprints, and comments (default false)
dry_runbooleannoIf 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
namestringyesSprint name (e.g. “Sprint 1”)
goalstringnoSprint goal describing what the team aims to accomplish
start_datestringyesStart date in YYYY-MM-DD format
end_datestringyesEnd 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
limitintegernoMaximum results to return (1–100, default 20)
cursorstringnoCursor 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:

NameTypeRequiredDescription
sprint_idstringyesUUID of the sprint to update
namestringnoNew sprint name
goalstringnoNew sprint goal (or null to clear)
start_datestringnoNew start date in YYYY-MM-DD format
end_datestringnoNew 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:

NameTypeRequiredDescription
sprint_idstringyesUUID 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:

NameTypeRequiredDescription
sprint_idstringyesUUID 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:

NameTypeRequiredDescription
sprint_idstringyesUUID 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:

NameTypeRequiredDescription
sprint_idstringyesUUID 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:

NameTypeRequiredDescription
sprint_idstringyesUUID of the sprint to delete
cascadebooleannoForce-delete the sprint and unlink its tickets (default false)
dry_runbooleannoPreview 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:

NameTypeRequiredDescription
namestringyesTeam name
descriptionstringnoTeam 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:

NameTypeRequiredDescription
team_idstringyesUUID of the team to delete
cascadebooleannoForce-delete the team and remove all associations (default false)
dry_runbooleannoPreview 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:

ModeDescription
noneTransitions are not enforced — tickets can move to any status freely (default)
warnInvalid transitions are allowed but produce a warning in the response
strictOnly transitions defined in the workflow are allowed — invalid transitions are rejected with an error

Parameters:

NameTypeRequiredDescription
namestringYesThe workflow name (e.g. “Default”, “Bug Triage”)
statusesarrayYesList of status objects, each with name (string) and category (“todo”, “in_progress”, or “done”)
transitionsarrayYesList of transition objects, each with from (string) and to (string) status names
enforcementstringNoEnforcement 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:

NameTypeRequiredDescription
limitintegerNoMaximum number of results (1–100, default 20)
cursorstringNoCursor 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:

NameTypeRequiredDescription
workflow_idstringYesThe UUID of the workflow to update
namestringNoNew name for the workflow
statusesarrayNoNew list of statuses (replaces all existing)
transitionsarrayNoNew list of transitions (replaces all existing)
enforcementstringNoNew 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:

NameTypeRequiredDescription
workflow_idstringyesUUID 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:

NameTypeRequiredDescription
workflow_idstringyesUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID 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:

NameTypeRequiredDescription
emailstringnoEmail address of the person to invite (omit for a generic invite link)
rolestringnoRole 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
user_idstringyesUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID of the project
user_idstringyesUUID 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:

NameTypeRequiredDescription
project_idstringyesUUID 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:

NameTypeRequiredDescription
namestringyesHuman-readable name for the key (1–255 characters)
scopesarray of stringsnoPermissions: read, write, admin. Defaults to ["read", "write"]
project_idsarray of stringsnoUUIDs 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:

NameTypeRequiredDescription
idstringyesUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
limitintegernoMax results per page (1–100, default 20)
cursorstringnoPagination 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
project_idstringyesUUID of the project
datestringyesDate the work was done (YYYY-MM-DD)
duration_minutesintegeryesDuration of work in minutes
activity_typestringyesOne of the 13 activity types (see below)
descriptionstringnoDescription of the work done

Activity types (all 13):

Activity TypeDescription
CodingWriting or modifying application code
TestingWriting or running tests
CodeReviewReviewing pull requests and code
DesignUI/UX design work
ArchitectureSystem design and architecture decisions
PMProject management tasks
RequirementsGathering and documenting requirements
TrainingLearning, onboarding, or training activities
MaintenanceInfrastructure and dependency maintenance
BugFixingInvestigating and fixing bugs
DocumentationWriting or updating documentation
DeploymentDeploying, releasing, or CI/CD work
MeetingsMeetings, 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:

NameTypeRequiredDescription
time_entry_idstringyesUUID 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:

NameTypeRequiredDescription
time_entry_idstringyesUUID of the time entry to update
datestringnoNew date (YYYY-MM-DD)
duration_minutesintegernoNew duration in minutes
descriptionstringnoNew description
activity_typestringnoNew 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:

NameTypeRequiredDescription
time_entry_idstringyesUUID 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:

NameTypeRequiredDescription
ticket_idstringyesUUID of the ticket
limitintegernoMax results per page (1–100, default 20)
cursorstringnoPagination 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:

NameTypeRequiredDescription
user_idstringnoUUID of the user (defaults to authenticated user)
limitintegernoMax results per page (1–100, default 50)
cursorstringnoPagination 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:

NameTypeRequiredDescription
periodstringyesPeriod in YYYY-MM format (e.g. “2026-03”)
group_bystringnoGroup results by “team” or “user”
team_idstringnoFilter by team UUID
user_idstringnoFilter by user UUID
cost_center_idstringnoFilter by cost center ID
activity_typestringnoFilter by activity type (e.g. “Coding”, “Design”)
tagstringnoComma-separated tag filters in key:value format
include_usersbooleannoInclude per-user breakdown within each project
include_budgetbooleannoInclude 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:

NameTypeRequiredDescription
time_entry_idstringyesUUID 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:

NameTypeRequiredDescription
time_entry_idstringyesUUID 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:

NameTypeRequiredDescription
periodstringyesPeriod in YYYY-MM format (e.g. “2026-03”)
team_idstringnoFilter by team UUID
cost_center_idstringnoFilter by cost center ID
activity_typestringnoFilter by activity type (e.g. “Coding”)
tagstringnoComma-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.

ParameterTypeRequiredDescription
org_idstringyesOrganization UUID
projectstringnoProject key to filter (e.g. PROJ)
formatstringnoExport 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.

ParameterTypeRequiredDescription
dataobjectyesThe 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

URINameDescription
alloy://projectsprojectsList all projects in the organization
alloy://user/{current_user_id}/assignedmy-assigned-ticketsTickets assigned to the current authenticated user

Resource Templates

URI PatternNameDescription
alloy://project/{key}projectGet a project by its key (e.g., PROJ)
alloy://ticket/{id}ticketGet a ticket by its UUID
alloy://sprint/{id}/boardsprint-boardBoard view for a sprint — tickets grouped by status
alloy://user/{id}/assigneduser-assigned-ticketsTickets 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):

ValueDescription
BacklogNot yet scheduled for work
TodoScheduled but not started
InProgressActively being worked on
InReviewWork complete, awaiting review
DoneCompleted and accepted
CancelledCancelled, will not be done

Tip: Use transition_ticket to 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.

ValueDescription
NoneNo priority set (default)
LowLow urgency
MediumNormal urgency
HighImportant, address soon
UrgentCritical, 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.

ValuePrivilege LevelDescription
Owner50Full control: billing, org deletion, and all admin capabilities
Admin40Manage members, invites, projects, and settings
Member30Create and manage tickets, sprints, and project content
Reporter20Create tickets and comments, but cannot manage projects or members
Viewer10Read-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.

ScopeDescription
readRead-only access: list and get operations (tickets, projects, members, etc.)
writeCreate and update operations: create tickets, log time, add comments, etc.
adminAdministrative 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

ValueDescription
PlannedSprint has not started
ActiveSprint is currently in progress
CompletedSprint has ended

Time Entry Status Values

Approval lifecycle for time entries.

ValueDescription
DraftEntry created, not yet submitted
SubmittedSubmitted for approval
ApprovedApproved by a manager
RejectedRejected, needs correction

Pagination

All list endpoints use cursor-based pagination. The response includes:

FieldTypeDescription
dataarrayArray of result items
next_cursorstring or nullCursor to pass for the next page, or null if no more results
has_morebooleantrue 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:

  1. First request: search_tickets with project_id and limit: 10
  2. Response includes "next_cursor": "eyJ...", "has_more": true
  3. Next request: same parameters plus cursor: "eyJ..."
  4. Continue until has_more is false

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).

CommandDescription
/alloy:assignAssign a ticket to a team member
/alloy:commentAdd a comment to a ticket
/alloy:create-ticketInteractively gather fields and create a new ticket
/alloy:inviteInvite a user to the organization
/alloy:log-workInteractively log time spent on a ticket
/alloy:moveMove a ticket to a new workflow status
/alloy:my-workFetch and summarize your assigned tickets by status
/alloy:new-projectCreate a new Alloy project
/alloy:pingCheck server connectivity and authentication status
/alloy:plan-sprintGuide sprint planning with backlog review and capacity
/alloy:project-summaryHigh-level overview of all projects and their health
/alloy:reportGenerate a project status report for a given period
/alloy:searchSearch for tickets by text, status, priority, or assignee
/alloy:sprint-statusReview active sprint progress and burndown trends
/alloy:standupGenerate 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:

ArgumentDescription
ticketTicket number to assign (e.g., PROJ-42) — skips asking for it
userTeam member name or email to assign to — skips asking for it

Behavior:

  1. If both ticket and user are provided, looks up the user via list_members and assigns immediately with assign_ticket
  2. If only ticket is provided, fetches ticket details with get_ticket and asks who to assign it to
  3. If only user is provided, asks which ticket to assign (can search via search_tickets)
  4. If neither is provided, asks for both interactively
  5. 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:

ArgumentDescription
ticketTicket number to comment on (e.g., PROJ-42) — skips asking for it

Behavior:

  1. If ticket is provided, fetches ticket details with get_ticket for context, then asks for the comment text
  2. If no ticket is provided, asks the user to identify the ticket first (can search via search_tickets)
  3. Posts the comment via add_comment
  4. 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:

ArgumentDescription
project_keyPre-fill the project key (e.g., PROJ) — skips asking for it
titlePre-fill the ticket title — skips asking for it

Behavior:

  1. Asks for the project key and title (unless pre-filled via arguments)
  2. Asks about optional fields: description, priority, ticket type, assignee, labels, sprint
  3. Calls create_ticket with all gathered fields
  4. 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:

ArgumentDescription
emailEmail address of the person to invite
roleRole to assign: admin, member, or viewer

Behavior:

  1. If both email and role are provided, calls create_invite immediately
  2. If only email is provided, asks which role to assign (explains the three options)
  3. If only role is provided, asks for the email address
  4. If neither is provided, asks for both interactively
  5. 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:

  1. Asks which ticket you worked on — uses search_tickets to find it if you provide a number or description
  2. Asks for duration (accepts natural language like “2 hours”, “90 minutes”), date (defaults to today), activity type, and optional description
  3. Activity types: Coding, Testing, CodeReview, Design, Architecture, PM, Requirements, Training, Maintenance, BugFixing, Documentation, Deployment, Meetings
  4. Confirms all details before logging
  5. Calls log_time with the collected parameters
  6. 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:

ArgumentDescription
ticketTicket number to move (e.g., PROJ-42) — skips asking for it
statusTarget status (e.g., In Progress, Done) — skips asking for it

Behavior:

  1. If ticket and status are both provided, calls transition_ticket immediately
  2. If only ticket is provided, fetches current status with get_ticket and asks for target status
  3. If only status is provided, asks which ticket to move (can search via search_tickets)
  4. If neither is provided, asks for both ticket and status interactively
  5. 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:

  1. Calls get_my_tickets to fetch your assigned tickets
  2. Groups results by status: In Progress, Open/To Do, In Review, Blocked, Done (recent)
  3. Highlights actionable items: high-priority tickets not yet started, stale tickets (no updates in 7+ days), tickets missing estimates or descriptions
  4. 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:

ArgumentDescription
nameName for the new project (e.g., My App)
keyShort project key (e.g., MYAPP) — uppercase letters, used in ticket IDs

Behavior:

  1. If both name and key are provided, calls create_project immediately
  2. If only name is provided, suggests an uppercase key derived from the name and asks for confirmation
  3. If only key is provided, asks for the project name
  4. If neither is provided, asks for the project name first, then suggests a key
  5. 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:

  1. Calls the ping tool to verify server connectivity
  2. Reports which server you are connected to, your authenticated user, and your organisation
  3. 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:

  1. Calls get_project_summary for current project and sprint state
  2. Calls search_tickets to find backlog items (Open/To Do, not in a sprint) and carryover tickets
  3. Reviews backlog sorted by priority (Critical → Low)
  4. Asks about team capacity (developers, sprint length)
  5. Recommends tickets to include based on priority, estimates, and capacity
  6. Flags dependencies and suggests a balanced mix of features, bugs, and tech debt
  7. 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:

  1. Calls get_project_summary to fetch all active projects
  2. Presents each project: name, key, ticket breakdown (open vs closed, by priority), sprint status (progress %, days remaining), recent activity
  3. 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)
  4. 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:

ArgumentDescription
periodReport period in YYYY-MM format (e.g., 2026-03) or natural language (e.g., this week)

Behavior:

  1. If period is provided, gathers data immediately from get_project_summary, get_sprint_burndown, get_time_report, and get_capitalization_report
  2. If no period is provided, asks the user to specify the reporting period first
  3. 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:

ArgumentDescription
querySearch query text (e.g., auth bug, high priority) — searches immediately

Behavior:

  1. If query is provided, calls search_tickets immediately with the query
  2. If no query, asks what the user is looking for (text, status, priority, assignee, labels, project)
  3. 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:

  1. Calls get_project_summary to identify active projects and sprints
  2. Calls get_sprint_burndown for each active sprint to get progress metrics
  3. Summarizes each sprint: name, date range, completed vs total points, burndown trend, tickets by status, at-risk items
  4. 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:

  1. Calls get_my_tickets to fetch your assigned tickets
  2. Yesterday: Identifies tickets recently moved to Done or In Review, or with activity in the last 24 hours
  3. Today: Lists In Progress or high-priority Open tickets as planned focus
  4. 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

ErrorCauseResolution
Failed to connect to Alloy API: ...API server unreachableVerify ALLOY_API_URL and that the server is running
Permission denied: ...Insufficient permissionsCheck your API key scopes and org role
Not foundInvalid UUID or deleted resourceVerify the ID exists using a search or list tool
Transition failed: ...Invalid workflow transitionCheck 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

Sprints & Boards

Sprints represent time-boxed iterations within a project. Each sprint moves through a lifecycle: PlannedActiveCompleted. 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:

FieldDescription
dateThe calendar date (ISO 8601)
total_ticketsTotal tickets assigned to the sprint on that day
completed_ticketsTickets in a done status category on that day
remaining_ticketstotal_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

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:

StatusCategoryMeaning
BacklogbacklogCaptured but not yet planned
TodotodoPlanned and ready to start
InProgressin_progressActively being worked on
InReviewin_reviewWork complete, awaiting review
DonedoneFinished
CancelledcancelledWill 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:

ModeInvalid TransitionHTTP Status
noneAllowed silently200
warnAllowed with warning in response200
strictRejected422

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 TriagedFixingVerifyingClosed).

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

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 TypeDescriptionCapEx Eligible
CodingWriting or modifying application codeYes
TestingWriting or running testsYes
CodeReviewReviewing pull requests and codeYes
DesignUI/UX design workYes
ArchitectureSystem design and architecture decisionsYes
PMProject management tasksDepends on phase
RequirementsGathering and documenting requirementsPlanning phase only
TrainingLearning, onboarding, or training activitiesNo
MaintenanceInfrastructure and dependency maintenanceNo (OpEx)
BugFixingInvestigating and fixing bugsYes (Development phase)
DocumentationWriting or updating documentationYes
DeploymentDeploying, releasing, or CI/CD workYes
MeetingsMeetings, standups, and ceremoniesDepends 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

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.

RoleDescription
OwnerFull control. Created automatically for the org creator.
AdminManage workflows, teams, invites, delete resources.
MemberCreate and update projects, tickets, sprints, time entries.
ReporterCreate tickets and comments. Limited to assigned projects.
ViewerRead-only access to all resources.

Full Permission Matrix

OperationMinimum Role
Orgs
Create / update orgOwner
Invites
Create / revoke inviteAdmin
List invitesAny authenticated
Workflows
Create / update / delete workflowAdmin
Teams
Delete teamAdmin
Projects
Create / update projectMember
Delete projectAdmin
Project Members
Add / remove project memberMember
List project membersAny authenticated
Tickets
Create ticketReporter (must be assigned to the project)
Update / transition ticketMember
Delete ticketAdmin
Comments
Create commentReporter
Update / delete commentAuthor or Admin
Sprints
Create / update / start / complete sprintMember
Delete sprintAdmin
Time Entries
Create / update / delete / submit time entryMember
Approve time entryAdmin
Labor Rates
All labor rate operationsAdmin

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.

RoleProject Visibility
Member and aboveAll projects in the organization
ReporterOnly projects where they are a project member
ViewerAll 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 StatusMeaning
401Missing or invalid authentication token
403Role is insufficient for the requested operation
404Resource 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}/members to 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

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_summary behind 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:

RoleWhoWhat they can do
OwnerYou (the EM)Everything — manage members, billing, settings
AdminTech leadsCreate projects, manage sprints, approve time
MemberEngineersCreate/edit tickets, log time, comment
ReporterStakeholdersCreate tickets and comments, view everything
ViewerExecs, auditorsRead-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:

  1. Create your organizationPOST /api/v1/orgs with your team name
  2. Create a project for each workstream — POST /api/v1/projects
  3. Set up a workflow — Create a strict workflow matching your process and assign it to your projects
  4. Invite your team — Send invites with appropriate roles (Admin for leads, Member for engineers)
  5. Create your first sprint — Plan a two-week iteration with a clear goal
  6. Add tickets — Break work into tickets with priorities and assignees
  7. Configure labor rates — Set hourly rates for capitalization reporting (POST /api/v1/users/{id}/orgs/{org_id}/labor-rates)
  8. Automate standups — Wire up the MCP /alloy:standup prompt 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:

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, and budget_cents fields. Reports use these to split CapEx from OpEx automatically.
  • Approval workflows built in. Time entries follow a Draft -> Submitted -> Approved lifecycle. 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:

FieldPurpose
capitalization_typeCapex or Opex — determines accounting treatment
development_phasePreliminary, AppDevelopment, or PostImplementation — ASC 350-40 phase
cost_center_idYour internal cost center code for GL mapping
budget_centsBudget in cents (e.g. 10000000 = $100,000.00)
budget_periodMonthly, Quarterly, Yearly, or Fixed
amortization_monthsUseful 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:

FieldDescription
budget_centsTotal budget for the period
budget_periodMonthly, Quarterly, Yearly, or Fixed
spent_centsApproved time cost for the period
budget_remaining_centsbudget_cents - spent_cents
budget_utilization_pctPercentage 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 TypeCapEx EligibleNotes
CodingYesCore development work
TestingYesWriting and running tests
CodeReviewYesPull request reviews
DesignYesUI/UX design work
ArchitectureYesSystem design decisions
DocumentationYesTechnical documentation
DeploymentYesCI/CD and release work
BugFixingYes (Development)Only during AppDevelopment phase
PMDependsCapitalizable during AppDevelopment
RequirementsPlanning onlyCapitalizable during Preliminary phase
MeetingsDependsContext-dependent
TrainingNoAlways OpEx
MaintenanceNoAlways 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:

  1. Request Viewer or Admin access — Viewer for read-only reports, Admin if you need to approve time entries or manage labor rates
  2. Verify cost centers — Ensure each project has the correct cost_center_id matching your GL chart of accounts
  3. Set capitalization fields — Confirm capitalization_type and development_phase on every project
  4. Configure labor rates — Set loaded rates for all team members with correct effective dates (POST /api/v1/labor-rates)
  5. Set budgets — Add budget_cents and budget_period to projects for budget tracking in reports
  6. Review submitted time — Check GET /api/v1/time-entries/submitted regularly and approve or reject entries
  7. Generate your first report — Run the capitalization report for the current period with include_budget=true
  8. Export CSV — Test the CSV export and verify it maps to your ERP import format
  9. Schedule automation — Set up cron jobs for weekly CSV exports and budget utilization alerts
  10. Tag for dimensions — Use tags like department:* and billable:* for multi-dimensional reporting

Further reading:

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=true and it migrates on startup — no external migration runner needed.
  • Automatic TLS. Pass --tls-domain and 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:

VariableDefaultPurpose
ALLOY_DATABASE_URLsqlite://alloy.dbsqlite://path or postgres://...
ALLOY_AUTO_MIGRATEtrueRun embedded migrations on startup
PORT3000HTTP listen port
ALLOY_REGISTRATIONopenopen or invite
ALLOY_CORS_ORIGINSComma-separated allowed origins
ALLOY_RATE_LIMIT_GLOBALRequests/min per IP (public endpoints)
ALLOY_RATE_LIMIT_AUTHRequests/min (authenticated endpoints)
ALLOY_HTTPStrue 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 status is not ok

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:

ScopeAccess
readGET endpoints only
writeGET + POST/PUT/PATCH/DELETE
adminFull 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_ids to 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:

EventFires when
ticket.createdA new ticket is created
ticket.updatedA ticket’s fields change
ticket.status_changedA ticket transitions status
comment.createdA comment is added
sprint.startedA sprint is started
sprint.completedA 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:

HeaderContent
X-Alloy-SignatureHMAC-SHA256 hex digest
X-Alloy-EventEvent type string
X-Alloy-DeliveryUnique 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:

  1. Deploy the binary — Build with cargo build --release, set ALLOY_DATABASE_URL and PORT, run alloy serve
  2. Verify healthcurl /health returns {"status": "ok"}
  3. Generate JWT keys — Create Ed25519 keypair, set ALLOY_JWT_PRIVATE_KEY_FILE and ALLOY_JWT_PUBLIC_KEY_FILE
  4. Enable TLS — Set --tls-domain and --tls-contact for automatic Let’s Encrypt provisioning
  5. Create CI API keysPOST /api/v1/api-keys with appropriate scopes, store in your secrets manager
  6. Set up webhooksPOST /api/v1/orgs/{org_id}/webhooks for ticket events your pipelines care about
  7. Configure GitHub integration — Set ALLOY_GITHUB_WEBHOOK_SECRET and create a GitHub webhook pointing to Alloy
  8. Add monitoring — Wire /health into your uptime monitor and alerting system
  9. Set rate limits — Configure ALLOY_RATE_LIMIT_GLOBAL and ALLOY_RATE_LIMIT_AUTH for your traffic patterns
  10. Back up your database — For SQLite: copy the .db file. For PostgreSQL: use pg_dump

Each step links to its respective section above. Start with steps 1–3 and iterate from there.


Further reading:

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:

KeyAction
j / kMove up and down
EnterOpen ticket detail
nNew ticket
eEdit ticket
/Search
qQuit

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:

  1. Switch to PostgreSQL — For multi-tenant isolation and concurrent access, point ALLOY_DATABASE_URL at a PostgreSQL instance

  2. 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": "..."
    }
    
  3. Add a workflow — Enforce your process with defined transitions

  4. Start sprints — Organize work into iterations when the team is ready

  5. 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:

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_cents and capitalization_type fields. 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_project behind 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_tickets with 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_summary and 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:

ActionMCP ToolWhat it does
Project overviewget_project_summaryTicket counts by status, active sprints
Create a ticketcreate_ticketAdd work to the backlog
Assign workassign_ticketSet the assignee on a ticket
Move a tickettransition_ticketChange status following workflow rules
Start a sprintstart_sprintKick off a planned sprint
Sprint burndownget_sprint_burndownTrack completion progress
Search ticketssearch_ticketsFind tickets by keyword, status, assignee
My ticketsget_my_ticketsSee what is assigned to you
Add a commentadd_commentLeave notes on a ticket
Tag a ticketset_tagsApply 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:

  1. Create your organizationPOST /api/v1/orgs
  2. Create a project — Set name, key, and org_id
  3. Configure budget — Update the project with budget_cents, capitalization_type, and development_phase
  4. Set up a workflow — Create a workflow matching your process, assign it to the project
  5. Create labels — Define categories (frontend, backend, design, bug, feature) at the org level
  6. Invite team members — Send invites with appropriate roles
  7. Add project members — Add each person to the project
  8. Create backlog tickets — Break work into tickets with priorities and descriptions
  9. Assign owners — Set an assignee on each ticket
  10. Create your first sprint — Plan a two-week iteration with a goal
  11. Start the sprint — Kick it off when the team is ready
  12. 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:

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:

PatternSignalAction
Flat remaining lineNo tickets finishingCheck blockers
Rising remainingScope creepCut scope or extend
Steep early dropTeam crushing itPlan for next sprint
Cliff at endLast-minute rushImprove 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:

FieldWhat to check
capitalization_typeCapex or Opex — determines accounting treatment
development_phasePreliminary, AppDevelopment, or PostImplementation
cost_center_idMatches your GL chart of accounts
budget_centsCorrect for the quarter
budget_periodQuarterly 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 list and alloy 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:

FieldDescription
project_idProject UUID
project_keyShort project key (e.g. PAY)
project_nameHuman-readable name
capitalization_typeCapex or Opex
development_phaseASC 350-40 phase
cost_center_idGL mapping code
total_hoursSum of approved hours
total_amount_centsHours multiplied by labor rates
breakdownPer-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:

FieldDescription
budget_centsTotal budget for the period
spent_centsApproved time cost for the period
budget_remaining_centsbudget_cents - spent_cents
budget_utilization_pctPercentage 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 TypeCapEx EligibleNotes
CodingYesCore development work
TestingYesWriting and running tests
CodeReviewYesPull request reviews
DesignYesUI/UX design work
ArchitectureYesSystem design decisions
DocumentationYesTechnical documentation
DeploymentYesCI/CD and release work
BugFixingYes (Development)Only during AppDevelopment phase
PMDependsCapitalizable during AppDevelopment
RequirementsPlanning onlyCapitalizable during Preliminary phase
MeetingsDependsContext-dependent
TrainingNoAlways OpEx
MaintenanceNoAlways 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:

  1. Audit project fields — Verify capitalization_type, development_phase, cost_center_id, and budget_cents on every project (Section 2)
  2. Verify labor rates — Confirm all engineers have current loaded rates with correct effective dates (Section 3)
  3. Approve all time — Clear the submitted queue; reject or return any entries that need correction (Section 4)
  4. Generate capitalization report — Run for the quarter period with include_budget=true (Sections 5-6)
  5. Review by cost center — Generate per-department reports for GL mapping (Section 7)
  6. Review by team — Generate team-level reports for management review (Section 8)
  7. Export CSV — Export full and filtered CSVs for ERP import (Section 9)
  8. Spot-check activity types — Filter by Maintenance and Training to verify OpEx classification (Section 10)
  9. Archive exports — Store CSV files in your document management system with the quarter label
  10. Update budgets — Set budget_cents for the next quarter on all active projects
  11. Adjust rates — Add new labor rates with future effective dates for any pending promotions or annual adjustments
  12. 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_report and get_time_report MCP tools to pull reports programmatically from any MCP-compatible client.


Further reading:

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:

RoleWho gets it
AdminTech leads who manage workflows and invites
MemberEngineers who create tickets, log time, run sprints
ReporterContractors or PMs who file tickets but shouldn’t manage projects
ViewerStakeholders 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:

StepDone?
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:

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

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)
  • curl and jq installed
  • 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)
  • curl and jq installed
  • A terminal with shell variable support (bash or zsh)

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.csv to 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)
  • curl and jq installed
  • 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_REGISTRATION environment variable:

  • open — anyone can register
  • invite_only — requires a valid invite code
  • disabled — 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).

ScopeGrants
readGET requests only
writeGET, POST, PATCH, PUT, DELETE
adminEverything 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 typeStorageLifetime
JWT access tokenClient-side only1 hour
Refresh tokenSHA-256 hash in DB30 days
API keySHA-256 hash in DBUntil 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

  1. Client calls /api/v1/auth/sso/discover with the org ID
  2. Alloy generates a PKCE code verifier and challenge
  3. Alloy returns the IDP authorization URL with embedded state
  4. User authenticates at the IDP (Okta, Google, etc.)
  5. IDP redirects back to /api/v1/auth/sso/callback with an auth code
  6. Alloy exchanges the code for IDP tokens using the PKCE verifier
  7. Alloy validates the ID token signature via the IDP’s JWKS endpoint
  8. 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

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:

ParameterValue
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:

ParameterValue
project_id<DEMO project UUID>
priorityHigh

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:

ParameterValue
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:

ParameterValue
ticket_id<Set up CI pipeline ticket UUID>
to_statusTodo

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:

ParameterValue
ticket_id<Set up CI pipeline ticket UUID>
to_statusDone

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:

ParameterValue
ticket_id<Set up CI pipeline ticket UUID>
bodyWill 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:

ParameterValue
ticket_id<Set up CI pipeline ticket UUID>
project_id<DEMO project UUID>
date2026-03-29
duration_minutes120
activity_typeCoding

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:

ParameterValue
project_id<DEMO project UUID>
titleFix Docker build cache invalidation
descriptionDocker layer caching breaks on dependency updates, causing 20-minute builds
priorityUrgent

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:

ParameterValue
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:

StepToolPurpose
1pingVerify connectivity
2whoamiCheck authenticated identity
3get_project_summaryHigh-level project overview
4search_ticketsFind tickets by priority
5assign_ticketTake ownership of work
6transition_ticketMove tickets through workflow (+ error case)
7add_commentCollaborate with notes
8log_timeTrack time spent
9create_ticketReport new work
10get_sprint_burndownMonitor 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-core has zero framework dependencies — no Axum, no SQLx, no Tokio. It contains only domain models, enums, service traits, and repository trait definitions.
  • alloy-api depends on alloy-core and provides concrete SQLx-backed repository implementations plus Axum HTTP handlers.
  • alloy-cli depends on both alloy-api and alloy-core (it embeds the server).
  • alloy-mcp and alloy-tui are HTTP clients — they talk to a running alloy-api instance over the network and do not link against it.
  • alloy-test-utils depends on alloy-core and 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

  1. The Authorization: Bearer <token> header is read by the AuthContext extractor (an Axum FromRequestParts implementation).
  2. Tokens prefixed alloy_live_ or alloy_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.
  3. All other tokens are validated as JWTs — signature verification uses the configured HMAC secret. JWT auth always grants scopes: "*".
  4. The resulting AuthContext carries user_id, org_id, email, role, scopes, and allowed_project_ids for downstream use.
  5. Handlers convert AuthContext into ActorContext (a framework-free struct in alloy-core) before calling service methods.

SQLite vs PostgreSQL Paths

Alloy supports two database backends selected by the ALLOY_DATABASE_URL environment variable:

AspectPostgreSQLSQLite
Use caseMulti-tenant productionSingle-tenant / local dev
Connectionpostgres://...sqlite://path or sqlite::memory:
Tenant isolationRow Level Security (RLS)org_id column filters in queries
Migrationsmigrations/postgres/migrations/sqlite/
Migration runnerrefinery embed_migrations!refinery embed_migrations!
UUID storageNative UUID typeTEXT
TimestampsTIMESTAMPTZTEXT (ISO 8601)
Auto-incrementSERIAL / BIGSERIALINTEGER PRIMARY KEY
Journal modeWAL by defaultWAL enabled at connection
Pool typesqlx::PgPoolsqlx::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 using sqlx::PgPool
  • sqlite_<domain>.rs — SQLite implementation using sqlx::SqlitePool

Tenant Isolation Model

Alloy enforces strict data isolation between organisations (tenants).

PostgreSQL: Row Level Security

  1. Every table has an org_id column.
  2. RLS policies restrict SELECT, INSERT, UPDATE, and DELETE to rows where org_id matches the session variable app.tenant_id.
  3. The tenant context middleware runs before every request. It extracts org_id from the auth token and stores it in a tokio::task_local.
  4. PostgreSQL repository methods call begin_tenant_tx() which starts a transaction and executes SET LOCAL app.tenant_id = '<org_id>'. This activates RLS filtering for the duration of the transaction.
  5. 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:

  1. Every query includes AND org_id = ? in its WHERE clause.
  2. The org_id parameter comes from the ActorContext passed through the service layer.
  3. 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/ and migrations/sqlite/. Migrations V1–V32. Last updated: 2026-04-02.

SQLite vs PostgreSQL Differences

AspectPostgreSQLSQLite
Primary keysUUID DEFAULT gen_random_uuid()TEXT (app-generated UUIDs)
TimestampsTIMESTAMPTZ DEFAULT now()TEXT DEFAULT (datetime('now'))
BooleansBOOLEAN (TRUE/FALSE)INTEGER (1/0)
JSON columnsJSONBTEXT (JSON stored as string)
Large integersBIGINTINTEGER
Multi-tenancyRow Level Security (RLS) policiesExplicit AND org_id = ? in queries
UUID functiongen_random_uuid()App-generated; migrations use hex(randomblob(...))
Tenant contextcurrent_setting('app.tenant_id')::uuidPassed as query parameter

Tables

schema_info

Tracks the Alloy version embedded in the database. Created in V1.

ColumnPG TypeSQLite TypeConstraints
keyTEXTTEXTPK
valueTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

organizations

Top-level tenant entity. Created in V2.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
nameTEXTTEXTNOT NULL
slugTEXTTEXTNOT NULL, UNIQUE
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): id = current_tenant_id() (V9)


users

Application users. Created in V3, amended in V22.

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V3
emailTEXTTEXTNOT NULL, UNIQUEV3
display_nameTEXTTEXTNOT NULLV3
password_hashTEXTTEXTNullableV3
activeBOOLEANINTEGERNOT NULL, DEFAULT TRUE/1V22
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV3
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV3

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.

ColumnPG TypeSQLite TypeConstraints
user_idUUIDTEXTPK (composite), FK → users(id)
org_idUUIDTEXTPK (composite), FK → organizations(id)
roleTEXTTEXTNOT NULL
joined_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_tenant_id() (V9)


teams

Groups of users within an organization. Created in V5.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
nameTEXTTEXTNOT NULL
descriptionTEXTTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_tenant_id() (V9)


team_memberships

Links users to teams. Created in V5.

ColumnPG TypeSQLite TypeConstraints
user_idUUIDTEXTPK (composite), FK → users(id)
team_idUUIDTEXTPK (composite), FK → teams(id)
joined_atTIMESTAMPTZTEXTNOT 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).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V6
org_idUUIDTEXTNOT NULL, FK → organizations(id)V6
team_idUUIDTEXTNullable, FK → teams(id)V6
keyTEXTTEXTNOT NULL, UNIQUE(org_id, key)V6
nameTEXTTEXTNOT NULLV6
descriptionTEXTTEXTNullableV6
ticket_counterINTEGERINTEGERNOT NULL, DEFAULT 0V6
workflow_idUUIDTEXTNullable, FK → workflows(id)V14
capitalization_typeTEXTTEXTNullableV17
development_phaseTEXTTEXTNullableV17
cost_center_idTEXTTEXTNullableV17
amortization_monthsINTEGERINTEGERNullableV17
budget_centsBIGINTINTEGERNullableV27
budget_periodTEXTTEXTNullableV27
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV6
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV6

RLS (PG): org_id = current_tenant_id() (V9)


tickets

Work items within a project. Created in V7, amended in V15 (sprint_id), V31 (indexes).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V7
project_idUUIDTEXTNOT NULL, FK → projects(id)V7
ticket_numberINTEGERINTEGERNOT NULL, UNIQUE(project_id, ticket_number)V7
titleTEXTTEXTNOT NULLV7
descriptionTEXTTEXTNullableV7
statusTEXTTEXTNOT NULL, DEFAULT ‘Backlog’V7
priorityTEXTTEXTNOT NULL, DEFAULT ‘None’V7
assignee_idUUIDTEXTNullable, FK → users(id)V7
reporter_idUUIDTEXTNOT NULL, FK → users(id)V7
sprint_idUUIDTEXTNullable, FK → sprints(id) ON DELETE SET NULLV15
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV7
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV7

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).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V8
org_idUUIDTEXTNOT NULL, FK → organizations(id)V8
user_idUUIDTEXTNOT NULL, FK → users(id)V8
nameTEXTTEXTNOT NULLV8
key_prefixTEXTTEXTNOT NULLV8
key_hashTEXTTEXTNOT NULL, UNIQUEV8
scopesTEXTTEXTNOT NULL, DEFAULT ‘*’V8
project_idsTEXTTEXTNOT NULL, DEFAULT ‘’V25
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT nowV8
last_used_atTIMESTAMPTZTEXTNullableV8
expires_atTIMESTAMPTZTEXTNullableV8

Indexes: idx_api_keys_key_hash(key_hash) UNIQUE


comments

Threaded comments on tickets. Created in V10.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
author_idUUIDTEXTNOT NULL, FK → users(id)
bodyTEXTTEXTNOT NULL
parent_comment_idUUIDTEXTNullable, FK → comments(id) ON DELETE CASCADE
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
entity_typeTEXTTEXTNOT NULL
entity_idTEXTTEXTNOT NULL
actionTEXTTEXTNOT NULL
actor_idUUIDTEXTNOT NULL
changesTEXTTEXTNOT NULL, DEFAULT ‘[]’
created_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
filenameTEXTTEXTNOT NULL
content_typeTEXTTEXTNOT NULL
size_bytesBIGINTINTEGERNOT NULL
s3_keyTEXTTEXTNOT NULL
uploaded_byUUIDTEXTNOT NULL, FK → users(id)
created_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id) ON DELETE CASCADE
nameTEXTTEXTNOT NULL
colorTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
ticket_idUUIDTEXTPK (composite), FK → tickets(id) ON DELETE CASCADE
label_idUUIDTEXTPK (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).

ColumnPG TypeSQLite TypeConstraintsMigration
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)V14
org_idUUIDTEXTNOT NULL, FK → organizations(id)V14
nameTEXTTEXTNOT NULL, UNIQUE(org_id, name)V14
statusesJSONBTEXTNOT NULL, DEFAULT ‘[]’V14
transitionsJSONBTEXTNOT NULL, DEFAULT ‘[]’V14
enforcementTEXTTEXTNOT NULL, DEFAULT ‘none’V29
created_atTIMESTAMPTZTEXTNOT NULLV14
updated_atTIMESTAMPTZTEXTNOT NULLV14

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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
project_idUUIDTEXTNOT NULL, FK → projects(id) ON DELETE CASCADE
nameTEXTTEXTNOT NULL
goalTEXTTEXTNullable
start_dateTEXTTEXTNOT NULL
end_dateTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘Planned’
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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).

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
user_idUUIDTEXTNOT NULL, FK → users(id)
ticket_idUUIDTEXTNOT NULL, FK → tickets(id) ON DELETE CASCADE
project_idUUIDTEXTNOT NULL, FK → projects(id) ON DELETE CASCADE
dateTEXTTEXTNOT NULL
duration_minutesINTEGERINTEGERNOT NULL
descriptionTEXTTEXTNullable
activity_typeTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘Draft’
approved_byUUIDTEXTNullable, FK → users(id)
approved_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
user_idUUIDTEXTNOT NULL, FK → users(id)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
loaded_rate_centsINTEGERINTEGERNOT NULL
effective_dateTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
org_idUUIDTEXTNOT NULL, FK → organizations(id)
urlTEXTTEXTNOT NULL
secretTEXTTEXTNOT NULL
event_typesTEXTTEXTNOT NULL
activeBOOLEANINTEGERNOT NULL, DEFAULT TRUE/1
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK
webhook_idUUIDTEXTNOT NULL, FK → webhooks(id) ON DELETE CASCADE
event_typeTEXTTEXTNOT NULL
payloadTEXTTEXTNOT NULL
statusTEXTTEXTNOT NULL, DEFAULT ‘pending’
response_statusINTEGERINTEGERNullable
response_bodyTEXTTEXTNullable
attemptINTEGERINTEGERNOT NULL, DEFAULT 0
next_retry_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idTEXTTEXTPK
tenant_idTEXTNOT NULL, DEFAULT current_setting(‘app.tenant_id’) (PG only)
ticket_idTEXTTEXTNOT NULL, UNIQUE(ticket_id, channel_id)
channel_idTEXTTEXTNOT NULL
thread_tsTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, UNIQUE, FK → organizations(id)
provider_typeTEXTTEXTNOT NULL, DEFAULT ‘oidc’
provider_nameTEXTTEXTNOT NULL, DEFAULT ‘okta’
issuer_urlTEXTTEXTNOT NULL
client_idTEXTTEXTNOT NULL
client_secretTEXTTEXTNOT NULL
authorization_endpointTEXTTEXTNullable
token_endpointTEXTTEXTNullable
jwks_uriTEXTTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id = current_setting('app.tenant_id')::UUID (V21)


invites

Organization membership invitations. Created in V23.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id)
emailTEXTTEXTNullable
invite_codeTEXTTEXTNOT NULL, UNIQUE
roleTEXTTEXTNOT NULL, DEFAULT ‘Member’
created_byUUIDTEXTNOT NULL, FK → users(id)
expires_atTIMESTAMPTZTEXTNOT NULL
accepted_atTIMESTAMPTZTEXTNullable
revoked_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now

RLS (PG): org_id::text = current_setting('app.tenant_id', true) (V23)


refresh_tokens

JWT refresh token tracking. Created in V24.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
user_idUUIDTEXTNOT NULL, FK → users(id)
token_hashTEXTTEXTNOT NULL, UNIQUE
expires_atTIMESTAMPTZTEXTNOT NULL
revoked_atTIMESTAMPTZTEXTNullable
created_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
idUUIDTEXTPK, DEFAULT gen_random_uuid() (PG)
org_idUUIDTEXTNOT NULL, FK → organizations(id) ON DELETE CASCADE
entity_typeTEXTTEXTNOT NULL
entity_idTEXTTEXTNOT NULL
tag_keyTEXTTEXTNOT NULL
tag_valueTEXTTEXTNOT NULL
created_atTIMESTAMPTZTEXTNOT NULL, DEFAULT now
updated_atTIMESTAMPTZTEXTNOT 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.

ColumnPG TypeSQLite TypeConstraints
project_idUUIDTEXTPK (composite), FK → projects(id) ON DELETE CASCADE
user_idUUIDTEXTPK (composite), FK → users(id) ON DELETE CASCADE
created_atTIMESTAMPTZTEXTNOT 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.

TableRLS PolicyIsolation Path
organizationsid = current_tenant_id()Direct
projectsorg_id = current_tenant_id()Direct
ticketsVia project → orgIndirect
teamsorg_id = current_tenant_id()Direct
org_membershipsorg_id = current_tenant_id()Direct
attachmentsVia ticket → project → orgIndirect
labelsorg_id = current_setting(...)::uuidDirect
ticket_labelsVia label → orgIndirect
workflowsorg_id = current_setting(...)::uuidDirect
sprintsVia project → orgIndirect
time_entriesVia project → orgIndirect
user_labor_ratesVia orgIndirect
webhooksVia orgIndirect
webhook_deliveries(Via webhook — no direct RLS)N/A
slack_thread_mappingstenant_id = current_setting(...)Direct (own column)
identity_providersorg_id = current_setting(...)::UUIDDirect
invitesorg_id::text = current_setting(...)Direct
entity_tagsorg_id = current_setting(...)::uuidDirect
project_membershipsVia project → orgIndirect

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

VersionDescription
V1Create schema_info
V2Create organizations
V3Create users
V4Create org_memberships
V5Create teams + team_memberships
V6Create projects
V7Create tickets
V8Create api_keys
V9Enable RLS (PG); no-op (SQLite)
V10Create comments
V11Create audit_logs
V12Create attachments
V13Create labels + ticket_labels
V14Create workflows; add projects.workflow_id
V15Create sprints; add tickets.sprint_id
V16Create time_entries
V17Add project capitalization fields
V18Create user_labor_rates
V19Create webhooks + webhook_deliveries
V20Create slack_thread_mappings
V21Create identity_providers
V22Add users.active
V23Create invites
V24Create refresh_tokens
V25Add api_keys.project_ids
V26Create entity_tags
V27Add project budget fields
V28Seed default workflows
V29Add workflows.enforcement
V30Create project_memberships
V31Add ticket indexes
V32Add 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,
}
}
FieldTypePurpose
user_idUserIdAuthenticated caller’s ID
org_idOrgIdOrganisation the request is scoped to
emailStringUser’s email address
roleOrgRoleHierarchical role (Owner > Admin > Member > Reporter > Viewer)
scopesStringComma-separated scopes or "*" for full access
allowed_project_idsStringComma-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 project
  • has_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
        }
    }
}
}
ServiceErrorApiErrorHTTP Status
NotFoundNotFound404
AlreadyExistsConflict409
ConstraintViolationValidation422
ValidationValidation422
PermissionDeniedForbidden403
InternalInternal500

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
}
ParameterTypePurpose
actor&ActorContextCaller identity for attribution
entity_typeimpl Into<String>Resource type (e.g. "ticket", "project")
entity_idimpl Into<String>ID of the affected entity
actionAuditActionCreate, Update, or Delete
changesVec<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

ServiceFileType ParamsRepos Used
ProjectServiceservices/project.rs<P, A>ProjectRepository, AuditLogRepository
TimeEntryServiceservices/time_entry.rs<T, A>TimeEntryRepository, AuditLogRepository
CommentServiceservices/comment.rs<C, A>CommentRepository, AuditLogRepository
ApiKeyServiceservices/api_key.rs<K, A>ApiKeyRepository, AuditLogRepository
AttachmentServiceservices/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:

  1. Always fetch first — this naturally produces NotFound for missing resources before checking permissions.
  2. Compare the owner field against actor.user_id. The field name varies by entity: author_id (comments), user_id (time entries), uploaded_by (attachments).
  3. Admin bypassactor.role.level() < OrgRole::Admin.level() gates the bypass. Admins and Owners can mutate any resource.
  4. Return PermissionDenied — not NotFound — 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 TEXT for 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 TIMESTAMPTZ for timestamps, UUID for 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!.

PostgreSQLSQLite
Pathmigrations/postgres/V{N}__create_widgets.sqlmigrations/sqlite/V{N}__create_widgets.sql
IDsUUIDTEXT
TimestampsTIMESTAMPTZTEXT
StringsTEXT (not VARCHAR)TEXT
Auto-incrementBIGSERIALINTEGER 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)

  1. Add a widget: Option<Router> field to the Routers struct
  2. Include routers.widget in the optional_routers array inside fn app()
  3. Construct the WidgetAppState and call widget_routes() in your server startup code (e.g., main.rs or build_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:

FileWhat to update
docs/api-reference.mdEndpoint docs, field tables, curl examples
docs/mcp-tools-reference.mdNew MCP tool parameters and behavior
docs/getting-started.mdIf the entity is part of the onboarding flow
docs/guides/*.mdAny concept guide that now covers widgets
scripts/seed-demo.shAdd seed data for the new entity

Quick-Reference File Map

StepFile(s)
Domain modelcrates/alloy-core/src/models.rs, crates/alloy-core/src/lib.rs
Repo traitcrates/alloy-core/src/repos.rs
SQLite repocrates/alloy-api/src/repos/sqlite_widget.rs, crates/alloy-api/src/repos/mod.rs
PostgreSQL repocrates/alloy-api/src/repos/pg_widget.rs, crates/alloy-api/src/repos/mod.rs
Migrationsmigrations/postgres/V{N}__*.sql, migrations/sqlite/V{N}__*.sql
Servicecrates/alloy-core/src/services/widget.rs, crates/alloy-core/src/services/mod.rs
Handler + routescrates/alloy-api/src/handlers/widget.rs, crates/alloy-api/src/handlers/mod.rs
App wiringcrates/alloy-api/src/lib.rs (Routers struct + app() fn)
CLIcrates/alloy-cli/src/commands/widget.rs, crates/alloy-cli/src/commands/mod.rs
MCPcrates/alloy-mcp/src/lib.rs
TUIcrates/alloy-tui/src/api.rs
Docsdocs/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

FieldSource env varDefault
Ed25519 private keyALLOY_JWT_PRIVATE_KEY / _FILEAuto-generated in dev
Ed25519 public keyALLOY_JWT_PUBLIC_KEY / _FILEAuto-generated in dev
IssuerALLOY_JWT_ISSUER"alloy"
AudienceALLOY_JWT_AUDIENCE"alloy-api"
TTL (seconds)ALLOY_JWT_TTL_SECONDS3600

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:

FieldTypeNotes
idApiKeyIdPrimary key
org_idOrgIdOwning organization
user_idUserIdCreating user
nameStringHuman-readable label
key_prefixStringFirst ~16 chars for identification
key_hashStringSHA256 hex digest for lookup
scopesString"*", or comma-separated: "read,write,admin"
project_idsStringComma-separated UUIDs, or empty for all projects
expires_atOption<DateTime<Utc>>Optional expiry
last_used_atOption<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

MethodReturnsLogic
has_scope(scope)bool"*" grants all; "admin" grants read+write; "write" implies read
can_access_project(id)booltrue if unrestricted or scopes="*" or id in list
require_write()Result403 if !has_scope("write")
require_project_access(id)Result403 if !can_access_project(id)
require_owner()Result403 if role != Owner
require_admin()Result403 if role < Admin
require_member_or_above()Result403 if role < Member
require_reporter_or_above()Result403 if role < Reporter
is_viewer()boolRole check
is_reporter()boolRole 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

RoleLevelGrants
Owner50Everything
Admin40All except owner-only actions
Member30Standard CRUD on assigned resources
Reporter20Create/update time entries, view projects
Viewer10Read-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

MethodLogic
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:

  1. At key creation: User specifies project_ids (comma-separated UUIDs).
  2. At validation: allowed_project_ids is copied into AuthContext.
  3. At request time: enforce_scopes middleware extracts the project ID from the URL path and calls can_access_project().
  4. 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:

ServiceErrorHTTP Status
NotFound404
AlreadyExists409
ConstraintViolation422
PermissionDenied403
Validation400
Internal500

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:

TablePolicy
organizationsid = current_tenant_id()
projectsorg_id = current_tenant_id()
ticketsproject_id IN (SELECT id FROM projects WHERE org_id = current_tenant_id())
teamsorg_id = current_tenant_id()
org_membershipsorg_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

AspectPostgreSQLSQLite
Isolation mechanismRLS policiesExplicit AND org_id = ?
Multi-tenantYesNo (single-tenant)
Tenant contextSET LOCAL app.tenant_idNot needed
Migration V9Creates RLS policies + functionNo-op
Defense-in-depthRLS + application checksApplication checks only

Scope Semantics

ScopeGrantsTypical use
"*"EverythingJWT auth (always)
"admin"read + write + admin actionsFull API key
"write"read + writeCI/CD automation keys
"read"read onlyDashboard/reporting keys

Scope inheritance: adminwriteread.


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.

StepCommand / ScriptWhat It Checks
1cargo fmt --all -- --checkCode formatting (rustfmt)
2cargo check --workspace --all-targetsCompilation (all crates, all targets)
3cargo clippy --workspace --all-targets -- -D warningsLint warnings treated as errors
4cargo test --workspace --all-featuresAll Rust unit and integration tests
5seed-demo.shAPI integration — creates demo data and asserts read-back
6test-mcp.shMCP server — JSON-RPC over stdio against live data
7test-permissions.shRBAC — all 5 roles across every endpoint
8test-tui-api.shTUI API paths — smoke-tests every route the TUI uses
9test-cli.shCLI end-to-end — every CLI command with API cross-validation
10verify-docs.shDocumentation — 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:

  1. Creates a temporary SQLite database file.
  2. Picks a random free port via Python.
  3. Starts alloy serve with ALLOY_AUTO_MIGRATE=true, ALLOY_REGISTRATION=open, and high rate limits.
  4. Waits up to 30 seconds for /health to return 200.
  5. Runs seed-demo.sh, which writes a token file consumed by later steps.
  6. Passes the token, org ID, and user ID as environment variables to subsequent scripts.
  7. 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_FILE for downstream scripts.

Environment variables:

VariableDefaultDescription
BASE_URLhttp://localhost:3000Server 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-mcp as a subprocess with BASE_URL, TOKEN, SEED_ORG_ID, and SEED_USER_ID environment variables.
  • Sends JSON-RPC tools/call requests for each MCP tool (list projects, create ticket, etc.).
  • Parses the JSON-RPC response and validates fields using assert_eq, assert_not_empty, and assert_contains helpers.
  • 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:

  1. 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).
  2. 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).
  3. Self-contained: If BASE_URL is 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-url and --format json flags to execute CLI commands programmatically.
  • Creates a fake $HOME directory so CLI credential storage doesn’t conflict with real user credentials.
  • Preserves $RUSTUP_HOME and $CARGO_HOME so 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:

  1. Starts its own temporary SQLite server and seeds it via seed-demo.sh.
  2. Collects all .md files from docs/, docs/tutorials/, and docs/guides/.
  3. A Python script extracts “bash/sh + json” block pairs from each file:
    • A ```bash block containing curl followed immediately by a ```json block is treated as a testable pair.
    • Script-like blocks (shebangs, loops, function definitions) are skipped.
  4. 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.
  5. Wildcard matching: "..." in expected JSON values means the field must exist but any value is accepted. Exact values are compared strictly.
  6. 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 ```json block must immediately follow the ```bash block (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

  1. Add the test in the same file as the code it tests, or in a tests/ module.
  2. Use #[sqlx::test] for PostgreSQL integration tests gated behind #[cfg(feature = "postgres-tests")].
  3. Use in-memory SQLite for fast tests that don’t need PostgreSQL-specific features.
  4. Run cargo test --workspace --all-features to verify.

New API Endpoint Test

When adding a new endpoint, add coverage in multiple layers:

  1. seed-demo.sh — Add a creation call and read-back assertion for the new resource.
  2. test-permissions.sh — Add check() calls for all 5 roles against the new endpoint.
  3. test-tui-api.sh — If the TUI uses the endpoint, add a check() call.
  4. test-cli.sh — If there’s a CLI command, add a run_cli() call with assertions.
  5. test-mcp.sh — If there’s an MCP tool, add a JSON-RPC test.
  6. Documentation — Add curl + json example pairs in the relevant docs so verify-docs.sh validates 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.sql
  • V17__add_project_capitalization.sql
  • V31__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.

ConceptPostgreSQLSQLite
StringsTEXTTEXT
Variable-length stringsUse TEXT (not VARCHAR)TEXT
Primary keysTEXT PRIMARY KEY (UUIDs as text)TEXT PRIMARY KEY
Auto-increment integersSERIAL or BIGSERIALINTEGER PRIMARY KEY AUTOINCREMENT
UUIDsUUID (native type)TEXT
TimestampsTIMESTAMPTZ NOT NULL DEFAULT NOW()TEXT NOT NULL DEFAULT (datetime('now'))
BooleansBOOLEAN NOT NULL DEFAULT falseINTEGER NOT NULL DEFAULT 0
JSONJSONBTEXT

Rules of thumb:

  • Always use TEXT instead of VARCHAR — works on both backends.
  • Use TEXT for UUIDs on both backends (PostgreSQL can cast, SQLite needs it).
  • Timestamps differ: TIMESTAMPTZ + NOW() for PG, TEXT + datetime('now') for SQLite.
  • Booleans differ: BOOLEAN for PG, INTEGER for 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 explicit AND 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:

  1. No runtime file access — the binary contains all migrations. No need to ship SQL files alongside the executable.
  2. Compile-time validation — if a migration file is malformed or missing, the build fails.
  3. Version tracking — refinery creates a refinery_schema_history table in the database to track which migrations have been applied.
  4. 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.sql and migrations/sqlite/V{N}__name.sql exist
  • Version number N is the next sequential integer (no gaps, no duplicates)
  • File names match the V{N}__{description}.sql pattern (double underscore)
  • cargo check --workspace passes (migrations compile into binary)
  • cargo test --workspace passes (migrations apply cleanly)

Common Pitfalls

PitfallSymptomFix
Missing SQLite counterpartPG tests pass, SQLite tests fail with version errorAdd no-op SQLite file
Using VARCHAR instead of TEXTWorks on PG, fails on strict SQLite parsersAlways use TEXT
Using NOW() in SQLiteSyntax errorUse datetime('now') for SQLite
Using BOOLEAN in SQLiteColumn created but stores 0/1 as textUse INTEGER for SQLite
Gap in version numbersrefinery refuses to runRenumber to fill the gap
Duplicate version numberCompile error from refineryUse next available number
Single underscore V1_namerefinery ignores the fileUse double underscore V1__name
Modifying an applied migrationChecksum mismatch error in productionWrite 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>,
}
}
FieldTypePurpose
configMcpConfigAPI base URL (api_url) and bearer token (api_token)
auth_contextMcpAuthContextValidated user identity (user_id, org_id, email, role)
tool_routerToolRouter<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:3000
  • ALLOY_API_TOKEN — must start with alloy_live_ or alloy_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:

  1. Creates a reqwest::Client
  2. Sets the Authorization: Bearer <api_token> header
  3. Sends the request
  4. Passes the response to handle_response() for status-based error mapping

Tool implementations follow a consistent pattern:

  1. Extract typed parameters from params.0
  2. Build a JSON body (for mutations)
  3. Construct the URL using self.config.api_url and path segments
  4. Call the appropriate api_* method
  5. 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:

PromptPurposeArguments
assignAssign ticket to team memberticket_id, project_key
create-ticketCreate new ticketproject_key, title
commentAdd comment to ticketticket_id
inviteInvite user to orgemail
log-workInteractive time loggingproject_key
moveTransition ticket statusticket_id, status
new-projectCreate new projectname, key
my-workSummarize assigned tickets
pingCheck connectivity
plan-sprintReview backlog, plan sprintproject_key
project-summaryHigh-level project overviewproject_key
reportGenerate project status reportproject_key
searchSearch ticketsquery
sprint-statusFetch active sprint burndownsproject_key
standupGenerate daily standup summaryproject_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 McpAuthContext is 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:

  1. Prefix check — token must start with alloy_live_ (production) or alloy_test_ (testing). Rejects malformed tokens before making any network call.
  2. API callGET /api/v1/auth/me with the token as a Bearer header. The Alloy API validates the token hash against the database and returns the associated user context.
  3. Identity resolution — the JSON response is deserialized into McpAuthContext with user_id, org_id, email, and role fields.

If validation fails, the server exits immediately with an error — no MCP requests are ever processed with an invalid token.

McpAuthError Variants

VariantCause
MissingTokenALLOY_API_TOKEN env var not set
InvalidTokenFormatToken doesn’t start with alloy_live_ or alloy_test_
UnauthorizedAPI 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 StatusMCP Error Message PrefixGuidance
200–299(success)Raw JSON body returned as text content
400Bad requestCheck parameters and required fields
401Authentication failedToken may be invalid or expired
403Permission deniedInsufficient role or project scope
404Not foundVerify entity ID exists
422Validation errorCheck field values and constraints
OtherAPI 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 PatternDescription
alloy://projectsList all projects
alloy://project/{key}Get project by key
alloy://ticket/{id}Get ticket by UUID
alloy://sprint/{id}/boardSprint board view
alloy://user/{id}/assignedTickets 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 implementation
  • macros#[tool_router], #[tool_handler], #[tool] procedural macros
  • transport-io — stdio transport support