Skip to content

Development

This page is for people working on Yak itself — fixing bugs in the Laravel app, adding new features, or writing new channel drivers. If you just want to run Yak against your repos, see the Setup page.

ToolVersionNotes
PHP8.4+With pdo_mysql extension
Composer2.xcomposer --version to verify
Node20+For building frontend assets and running Playwright
Docker24+For MariaDB via docker-compose

You do NOT need Claude Code CLI, Chromium, or Ansible to develop on Yak. Those are runtime dependencies for a production Yak instance — tests fake all external process calls via Laravel’s Process::fake() and Http::fake().

Terminal window
git clone https://github.com/geocodio/yak.git
cd yak
# Install PHP dependencies
composer install
# Install Node dependencies and build the dashboard
npm install
npm run build
# Start MariaDB
docker compose up -d
# Set up the environment
cp .env.example .env
php artisan key:generate
# Run migrations
php artisan migrate --seed

The starter kit’s default test user is seeded in database/seeders/DatabaseSeeder.php.

Terminal window
composer run dev

This starts the Laravel dev server, the queue worker, the scheduler, and Vite all in parallel via concurrently. Equivalent to running:

Terminal window
php artisan serve
php artisan queue:listen --tries=1
php artisan schedule:work
npm run dev

Open http://localhost:8000. The Livewire starter kit’s auth pages work for local development — Google OAuth is only used in production.

Yak has four test tiers. The first three run in CI on every push; the fourth is nightly only.

TierDirectoryHow to runSpeed
Unittests/Unit/vendor/bin/pest --testsuite=UnitSeconds
Featuretests/Feature/vendor/bin/pest --testsuite=FeatureSeconds
Browsertests/Browser/vendor/bin/pest --testsuite=Browser~30s
Contracttests/Contract/vendor/bin/pest --group=contract~60s, requires Claude CLI
Terminal window
# Everything except contract tests (matches CI)
vendor/bin/pest --exclude-group=contract
# Compact output (recommended for iterative work)
php artisan test --compact
# A single file
php artisan test --compact tests/Feature/RunYakJobTest.php
# A single test by name filter
php artisan test --compact --filter="creates a task when a valid Sentry webhook arrives"

Browser tests use Pest’s Playwright plugin. On first run:

Terminal window
npx playwright install --with-deps chromium

Then:

Terminal window
vendor/bin/pest --testsuite=Browser

Browser tests cover the auth flow, Livewire live updates on the task detail page, artifact viewer navigation, signed URL access, and accessibility (assertNoAccessibilityIssues() plus assertNoJavaScriptErrors() on dashboard pages).

Contract tests validate that real Claude CLI output matches the schema Yak expects. They run nightly against the real CLI — not in the normal test run — because they need Claude CLI installed and an Anthropic API key.

Terminal window
vendor/bin/pest --group=contract

If you change how Yak parses Claude CLI output (ClaudeOutputParser), add a contract test.

Two tools, both enforced in CI.

Laravel Pint for formatting. Run before committing:

Terminal window
vendor/bin/pint

Or check without fixing:

Terminal window
vendor/bin/pint --test

Yak uses the Laravel preset with one override: concat_space is set to one (space before and after .). See pint.json at the repo root.

PHPStan at level 8 (maximum) with the Larastan extension:

Terminal window
vendor/bin/phpstan analyse

Yak ships with a phpstan-baseline.neon file containing pre-existing errors (mostly Livewire dynamic property access). Do not clear the baseline without approval. New code should not add to it.

Not enforced. Developers can run Pint on save or set up a git pre-commit hook. CI is the gate.

See the Architecture page for the full system design. For contributors, the shortest version:

  • app/Jobs/ — the pipeline. RunYakJob, RetryYakJob, ResearchYakJob, SetupYakJob, ClarificationReplyJob, ProcessCIResultJob, CreatePullRequestJob, SendNotificationJob, ProcessWebhookJob, CleanupJob. Each agent job creates an Incus sandbox at the start and destroys it in a finally block.
  • app/Jobs/Middleware/EnsureDailyBudget. Cross-cutting concerns as Laravel job middleware.
  • app/Agents/SandboxedAgentRunner (the AgentRunner implementation), ClaudeCodeOutputParser, StreamEventHandler. The runner executes Claude Code inside the task’s Incus container via incus exec.
  • app/Services/IncusSandboxManager.php — sandbox lifecycle: clone from snapshot, configure resource limits, push Claude/MCP config, snapshot, promote-to-template, destroy.
  • app/Services/SandboxArtifactCollector.php — pulls .yak-artifacts/ from the sandbox before destruction.
  • app/Drivers/ — channel driver implementations. Each channel has an input driver, a notification driver, or both.
  • app/Contracts/ — the driver interfaces (InputDriver, CIDriver, NotificationDriver) plus CIBuildScanner and AgentRunner.
  • app/Http/Controllers/Webhooks/ — one invokable controller per webhook endpoint. Uses the VerifiesWebhookSignature trait. Note: there is no Drone CI webhook — Drone is polled via yak:poll-drone-ci instead.
  • app/Livewire/ — dashboard components. Tasks/TaskList, Tasks/TaskDetail, Repos/RepoList, Repos/RepoForm, CostDashboard, Health, HealthRow, Skills, PromptEditor, plus Settings/* and Actions/*.
  • app/Models/YakTask (note: $table = 'tasks'), TaskLog, Artifact, Repository, DailyCost, AiUsage, Prompt, PromptVersion, GitHubInstallationToken, LinearOauthConnection.
  • app/Enums/TaskStatus (the state machine), TaskMode, NotificationType. The state machine uses the artisan-build/fat-enums composer package.
  • app/Services/ — external API integrations (GitHub, Linear, Slack, Sentry), detection logic (RepoDetector, RepoRouter), and the PromptResolver that renders prompts with DB overrides.
  • app/Prompts/PromptDefinitions (metadata for every prompt slug) and PromptFixtures (sample data used by the in-app editor preview).
  • app/YakPromptBuilder.php — entry point for task/system prompt assembly. Delegates rendering to the Prompts facade → PromptResolver, which prefers DB-stored overrides (edited via the /prompts page) and falls back to the canonical Blade template.
  • app/GitOperations.php — centralized git commands via the Process facade.
  • app/Providers/ChannelServiceProvider.php — registers webhook routes conditionally based on which channels have credentials configured.
  • resources/views/prompts/ — Blade templates. These are the defaults for every prompt slug; the in-app editor persists overrides to the prompts table.
  • docker/ — production Docker configuration. The root Dockerfile builds from it.

Yak’s channel architecture is the primary extension point. Adding a new input source (Jira, GitHub Issues, email, etc.), a new CI system, or a new notification target means implementing one or more of the contracts in app/Contracts/.

app/Contracts/InputDriver.php
interface InputDriver
{
public function parseWebhook(Request $request): ?TaskDescription;
// Returns null if the webhook should be ignored.
}
// app/Contracts/CIDriver.php
interface CIDriver
{
public function parseBuildResult(Request $request): ?BuildResult;
public function fetchFailureOutput(string $buildId): string;
}
// app/Contracts/NotificationDriver.php
interface NotificationDriver
{
public function acknowledge(YakTask $task): void;
public function progress(YakTask $task, string $message): void;
public function result(YakTask $task): void;
public function failed(YakTask $task, string $reason): void;
}

Worked Example: Adding A Jira Input Driver

Section titled “Worked Example: Adding A Jira Input Driver”
  1. Add configuration

    In config/yak.php, add a jira entry under channels:

    'jira' => [
    'base_url' => env('JIRA_BASE_URL'),
    'api_token' => env('JIRA_API_TOKEN'),
    'webhook_secret' => env('JIRA_WEBHOOK_SECRET'),
    ],

    The Channel helper class (app/Channel.php) auto-detects channels as enabled when their credentials are present.

  2. Create the input driver

    Terminal window
    php artisan make:class Drivers/JiraInputDriver

    Implement InputDriver. Parse the incoming Jira webhook payload into a TaskDescription (source = jira, external_id from issue key, context from issue body).

  3. Create the webhook controller

    Terminal window
    php artisan make:controller Webhooks/JiraWebhookController --invokable

    Use the VerifiesWebhookSignature trait for signature checking. Resolve the input driver, parse the request, create a YakTask, dispatch RunYakJob. Look at SlackWebhookController for the canonical pattern.

  4. Register the route conditionally

    Add the channel to the CHANNEL_CONTROLLERS map in app/Providers/ChannelServiceProvider.php:

    private const CHANNEL_CONTROLLERS = [
    'slack' => SlackWebhookController::class,
    'linear' => LinearWebhookController::class,
    'sentry' => SentryWebhookController::class,
    'jira' => JiraWebhookController::class,
    ];

    The provider auto-registers POST /webhooks/{channel} only when (new Channel($channel))->enabled() returns true.

  5. Add a notification driver (optional but recommended)

    Create app/Drivers/JiraNotificationDriver.php implementing NotificationDriver. Post issue comments via the Jira REST API (use Http::withToken()). If omitted, notifications fall back to PR comments.

  6. Create a prompt template

    Add resources/views/prompts/tasks/jira-fix.blade.php following the pattern of tasks/linear-fix.blade.php. Keep it short. This is the default; operators can override it at runtime via the in-app Prompts editor (the prompts table).

  7. Register the slug and wire up rendering

    • Add an entry for tasks-jira-fix to app/Prompts/PromptDefinitions.php (view path, label, category, variables).
    • Add a sample fixture for the editor preview to app/Prompts/PromptFixtures.php.
    • Add a render helper in app/YakPromptBuilder.php and route the jira source to it in taskPrompt().
  8. Write tests

    • tests/Unit/JiraInputDriverTest.php — parses sample webhook payloads, returns expected task description, rejects invalid payloads
    • tests/Feature/JiraWebhookTest.php — full controller test: valid payload creates task, invalid signature rejected, duplicate external_id rejected
    • tests/Feature/JiraNotificationTest.php — uses Http::fake() to assert correct API payloads
  9. Add Ansible support (for production deployment)

    Create ansible/roles/channel-jira/ with tasks for registering the webhook on the Jira side. Add the channel to the conditional includes in ansible/playbook.yml. Follow ansible/roles/channel-linear/ as a template.

  10. Document it

    Add a Jira section to the Channels page following the pattern of Linear and Sentry.

Same pattern, but implement CIDriver (or CIBuildScanner for pull-based systems) instead of InputDriver. GitHub Actions posts check-run results to POST /webhooks/ci/github. Drone has no outbound webhook, so its results are polled by the yak:poll-drone-ci scheduled command (see app/Console/Commands/PollDroneCiCommand.php and app/Services/DroneBuildScanner.php). The repo’s ci_system column is the authority on which driver to use for a given repo.

Every model has a factory with named states. Factories are the only way to create test data — no raw DB inserts in tests.

$task = YakTask::factory()
->awaitingClarification()
->forRepo('my-app')
->create();

Key factory states:

ModelStates
YakTaskpending, running, awaitingClarification, awaitingCi, retrying, success, failed, expired
Repositorydefault, inactive, withAuth, withSentry
TaskLoginfo, warning, error
Artifactscreenshot, video, research

tests/Helpers/ provides reusable helpers loaded via Pest’s uses() in tests/Pest.php:

HelperPurpose
fakeClaudeRun()Fakes a successful Claude CLI run with configurable result_summary, cost_usd, session_id, num_turns
fakeClaudeClarification()Fakes a Claude run that returns clarification JSON
fakeClaudeError()Fakes a failed Claude run
assertSlackThreadReply()Asserts an HTTP call to Slack chat.postMessage with correct channel/thread/text
assertLinearActivity()Asserts a Linear agent session activity was posted
assertLinearStateUpdate()Asserts a Linear issue’s state was updated

All external process calls (Claude CLI, git, docker-compose) use Process::fake(). Patterns matter — specific patterns before wildcards, because Laravel matches in registration order:

Process::fake([
'claude -p *' => Process::result(json: ['result' => '...', 'session_id' => '...']),
'*' => Process::result(),
]);

External API calls use Http::fake() with URL patterns:

Http::fake([
'slack.com/api/chat.postMessage' => Http::response(['ok' => true]),
'api.linear.app/graphql' => Http::response(['data' => ['...']]),
]);

All feature tests use SQLite in-memory via RefreshDatabase (configured globally in tests/Pest.php). The application uses MariaDB in development and production, but tests use SQLite in-memory for speed — no test database container needed.

Pest it() syntax with descriptive names:

it('creates a task when a valid Sentry webhook arrives', function () { ... });
it('rejects Sentry webhooks for CSP violations', function () { ... });
it('detects clarification JSON and pauses for user reply', function () { ... });
  1. Fork the repo and create a branch off main

  2. Make your changes with tests

  3. Run the full pre-flight check:

    Terminal window
    vendor/bin/pint
    vendor/bin/phpstan analyse
    vendor/bin/pest --exclude-group=contract
  4. Open a PR using the template (.github/pull_request_template.md): What, Why, How to test, Checklist

  5. CI runs Pint, PHPStan, unit tests, feature tests, and browser tests on every push

  6. A maintainer reviews, and if all four checks pass, merges

  • phpstan-baseline.neon — pre-existing errors, do not clear
  • docker/supervisord.conf — production config
  • .chief/ — local working files, never commit
  • Bug reporthttps://github.com/geocodio/yak/issues/new?template=bug_report.yml
  • Feature requesthttps://github.com/geocodio/yak/issues/new?template=feature_request.yml

Include the Yak version (git SHA), the channel involved, steps to reproduce, and relevant logs from docker logs yak --tail 500 or the task’s debug section.