spatie/there-there-cli
A command-line tool for There There that lets you interact with the There There API from your terminal. Install it globally with Composer, log in per workspace profile, and manage tickets—list, show, reply, forward, and update—right from the CLI. Full docs at there-there.app.
Accept the mental model first: this is not a package you composer require into an app. It's a Laravel Zero CLI compiled to a PHAR (builds/there-there), installed globally, one command per There There API endpoint generated from a bundled OpenAPI spec. There is no service provider, no facade, no ThereThere::tickets()->create(). Your integration surface is stdout and the exit code.
Minimal setup:
composer global require spatie/there-there-cli
composer global config bin-dir --absolute # add this to PATH
there-there login # profile auto-named after your workspace
there-there get-me # verify: returns your user + workspace
Grab the token from https://there-there.app/settings/account/api-tokens.
First real use case — triage from a terminal instead of the web UI:
there-there list-tickets --filter-status=open --filter-assigned-to-me=true
there-there show-ticket --ticket=01J...
there-there reply-to-ticket --ticket=01J... --field body="Thanks, looking into this now."
Where to look first, in order:
there-there <command> --help — the authoritative parameter list. Every command is self-documenting; the README is only a sampling.there-there install-skill — drops the agent skill (and its references/commands.md) into your repo. Even if you never use a coding agent, that reference file is the best offline command catalogue available.This is the pattern that actually pays off. Wrap the binary in Symfony\Component\Process\Process, decode JSON, branch on exit code.
use Symfony\Component\Process\Process;
function thereThere(array $args, ?string $profile = null): array
{
$process = new Process([
config('services.there_there.bin', '/usr/local/bin/there-there'),
...$args,
...($profile ? ["--profile={$profile}"] : []),
]);
$process->setTimeout(120)->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(
"there-there failed ({$process->getExitCode()}): ".$process->getErrorOutput()
);
}
return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
}
Note the explicit binary path. Cron and queue workers routinely have a different PATH than your interactive shell, and "works locally, sh: there-there: not found in production" is the single most common failure here.
The highest-value automation: a nightly command that lists open tickets past their SLA and posts to Slack, no There There webhook plumbing required.
$tickets = thereThere([
'list-tickets',
'--filter-status=open',
'--filter-created-before='.now()->subDays(3)->toDateString(),
])['data'];
foreach ($tickets as $ticket) {
// escalate, notify, or bulk-remediate
}
Keep the digest read-only. Anything that mutates goes behind a manual --dry-run flag you flip off deliberately — see the wrong-workspace hazard below.
Renames, tag backfills, and re-assignments after a team change are where this tool shines, precisely because they're one-offs you don't want to write a migration and a job class for.
for ulid in $(there-there list-tickets --filter-tag-ulids=OLD_TAG --profile=spatie \
| jq -r '.data[].ulid'); do
there-there add-tag-to-ticket --ticket="$ulid" --tag=NEW_TAG
there-there remove-tag-from-ticket --ticket="$ulid" --tag=OLD_TAG
done
Loop in bash, not in PHP. One process per ticket is slow but debuggable; each invocation is independently re-runnable, so a mid-run failure doesn't leave you guessing where it stopped.
--field is the write primitiveMutations take a --field name=value flag, repeated per field, with JSON for arrays and enums passed as plain strings:
there-there reply-to-ticket --ticket=01J... --field body="Your reply here"
there-there forward-ticket --ticket=01J... --field to_recipients='["[email protected]"]'
there-there add-note-to-ticket --ticket=01J... --field body="Internal note"
there-there update-ticket-status --ticket=01J... --field status=closed
Quote every value. Shells eat [, ], and unescaped spaces; a swallowed array argument surfaces as a validation error from the API that names the field but not your quoting.
--profile is the clean way to run one command against several workspaces in a single job:
for p in spatie ohdear; do
there-there list-tickets --filter-status=open --profile="$p" > "open-$p.json"
done
Prefer per-invocation --profile over there-there use for anything scripted. use mutates shared global state in ~/.there-there/config.json and will silently change the target of every later invocation in that environment.
show-ticket exposes attachments[] and inline_images[] per message, each with a media ULID. Fetch bytes with download-attachment, redirecting to a file:
there-there download-attachment --media=MEDIA_ULID > screenshot.png
Then verify it's actually an image and not an error page — file screenshot.png is a cheap assertion to put in any pipeline that ingests attachments.
there-there install-skill commits a skill definition into the repo. Treat it as team documentation with a bonus: on-call engineers and coding agents both get the same curated command reference, kept in the repo where it's reviewable.
Pin the version, and check what you pinned. The PHAR in builds/ is what Composer installs, and nothing rebuilds it automatically — this repo shipped 0.8.0 with a PHAR built for 0.7.1, meaning the tagged release didn't contain the release notes' commands. Assert there-there --version as a step in any CI job that depends on a specific command:
there-there --version # expect 0.8.2+
Command names have changed and will change again. Commands derive from the OpenAPI spec's operationIds: get-tickets-ticket became show-ticket, get-tickets became list-tickets, patch-company became update-company. Any hardcoded command string is a breakage waiting on an unattended composer global update. Pin versions in your Docker images and treat CLI upgrades as a code change with a migration note.
The response envelope moved in 0.5.0. Single resources are now wrapped under a data key. Any parser written against pre-0.5 output will silently return null rather than erroring, so validate the shape on the first call of a run and fail loudly if data is missing.
The wrong-workspace mutation is the highest-blast-radius risk. Profiles are convenience and hazard: a scheduled job that reads from --profile=spatie will happily write to spatie too if a colleague ran there-there use spatie on the box. Make the profile explicit on every write, never rely on the ambient default, and log the resolved workspace from get-me at the start of any mutating run.
401 vs everything else. Pre-0.8.1, any non-2xx on login printed Invalid API token. — masking expired tokens, rate limits, and 500s under one misleading message. Genuine 401s still say that; everything else now reports the real status. If you see Invalid API token. today it really is the token, and there-there login is the fix.
download-attachment was corrupt until 0.8.2. Response bodies came out behind a Response is not JSON (...) line, with a trailing newline, and the console formatter ate anything that looked like <...>. Any attachment pipeline built on an older version produced subtly broken files. Upgrade and re-test rather than assuming your redirect logic was wrong.
~/.there-there/config.json holds real API tokens, now written 0600. Don't copy it into containers as a build artifact, don't bake it into images, and don't let it land in a backup that's more broadly readable than the tokens themselves. Inject credentials at runtime with login in an entrypoint, or provision the file with matching permissions.
TLS verification is on by default, disabled only for localhost, 127.0.0.1, ::1, and *.test. If you're pointing at a staging host with a self-signed cert, that's a hard failure by design — add the CA to the trust store rather than reaching for a bypass flag that doesn't exist.
Pagination is not automatic. list-tickets is paginated; a naive jq '.data[].ulid' gets you page one. Check --help for the page parameter and loop until you get a short page, or bound your sweep to a date filter you know is smaller than one page.
No connection reuse, no retries. Every command is a fresh process doing a fresh TLS handshake, and the CLI won't retry a 502. For anything touching more than a few dozen tickets, add retry-with-backoff around the Process call in PHP rather than in the bash loop, and set setTimeout explicitly — the default will bite you on a slow list.
Extension point: the bundled OpenAPI spec. The command set is generated from a spec file in the repo via spatie/laravel-openapi-cli. When the API adds an endpoint and the CLI lags, your options are (a) wait for a spec resync, or (b) hit the API directly with Http::withToken(...) for that one endpoint and keep the CLI for everything else. Mixing is fine; document which is which so the next person doesn't hunt for a command that was never generated. If you find yourself needing (b) for more than a couple of endpoints, you've outgrown shelling out and should build the in-app integration instead.
How can I help you explore Laravel packages today?