Work with darvis/api-x. Use it to post a text with an image to X from Laravel code, the x:post command or the post-update MCP tool, to read mentions and answer them with a reply, to write a release announcement, to handle a refused post and to test all of it without calling X.
php artisan boost:add-skill darvis/api-x
Save this content to: .claude/skills/api-x-development/SKILL.md
# darvis/api-x development
## When to use this skill
Use this skill when code or an agent posts to X in an application that has `darvis/api-x` installed, when it reads the posts that mention the account or answers them, when a post is refused with an `XException`, when you announce a release on X, or when you write tests around any of this.
## Setting it up
Run `php artisan x:install`. It asks for the four keys, checks them with `XClient::account()` (one billed read), sets `X_SUBSCRIPTION`, `X_DAILY_LIMIT`, `X_ALLOW_LINKS` and `X_DRY_RUN`, adds the server to `.mcp.json` and ends with a dry run. In a script: `php artisan x:install --consumer-key=... --consumer-secret=... --access-token=... --access-token-secret=... --verify --mcp --no-interaction`.
## How a post runs
`XClient::post($text, $image = null, $dryRun = false)` runs these steps in order and stops at the first failure:
1. Trim the text; an empty text throws `The post text is empty.`
2. Count it the way X does (most Latin characters 1, emoji and CJK 2, a link 23). Above the limit it throws `The post text counts N characters on X; the maximum is M.` The limit is 280, or 25,000 when `X_SUBSCRIPTION` is `basic`, `premium` or `premium_plus`; read it with `XConfig::subscription()->maxLength()`.
3. Without `X_ALLOW_LINKS=true`, a URL, `www.` or a bare domain such as `arvid.nl` throws the link message.
4. When the daily limit is reached it throws `The daily limit of N posts is reached.`
5. Read and check the image: it must exist, be a JPEG, PNG, GIF or WebP and be at most 5 MB.
6. In a dry run, return a `PostResult` with `dryRun` true and no id.
7. Without all four keys it throws `X credentials are missing.`
8. Upload the image to `/2/media/upload` (multipart, `media_category` `tweet_image`), then create the post at `/2/tweets` with the media id.
## Writing a good post
- Keep release announcements short, well under 280 characters, even when the account has an X subscription: one or two sentences and at most three short points. Only go longer when the owner asks for a long post.
- Leave links out. The image and the package name already say what it is about.
- Write in the voice of the account owner, in the language the owner uses on X.
## Reading mentions and replying
`XClient::mentions($sinceId = null, $limit = 10)` (or `x:mentions`, or the MCP tool `list-mentions`) returns the newest posts that mention the account, newest first, without the account's own posts. `$limit` is kept between 5 and 100. A reply to one of the account's posts has `inReplyToId`, and `inReplyToText` when that post is in the `x_posts` history.
| Step | What happens | Cost |
| --- | --- | --- |
| First call | `GET /2/users/me` for the account id, then cached per access token | one billed read, once |
| Every call | `GET /2/users/{id}/mentions` with `since_id` and `max_results` | every post returned is a billed read |
| Reply | `POST /2/tweets` with `reply.in_reply_to_tweet_id` | like any post; with a link much more |
Pitfalls:
- Always pass the newest id of the previous read as `since_id`. Reading without it bills the same posts again on another day.
- Never poll mentions on a schedule of minutes or on page loads; read when someone asks.
- X only allows a reply to a post that mentions or quotes the account. Reply to a mention, not to some other post in the thread, or X answers 403.
- A reply counts towards `X_DAILY_LIMIT` and is refused with a link unless `X_ALLOW_LINKS=true`.
- `X_DRY_RUN` does not stop reading; it only concerns posting.
## Writing a good reply
- Show every reply to the owner before sending it, unless the owner said to send without asking. It is public, under their name.
- Answer the person, not the crowd: short, specific, in the owner's voice and language. One or two sentences is usually right.
- Not everything needs an answer: a plain "nice!" can get a short thanks, spam gets nothing. Leave prices, complaints and legal matters to the owner.
- Don't start with the @handle; X adds the reply context itself.
@verbatim
<code-snippet name="Answer the newest mentions" lang="php">
use Darvis\ApiX\XClient;
$client = app(XClient::class);
foreach ($client->mentions(sinceId: $lastSeenId) as $mention) {
// Draft an answer, let the owner approve it, then:
$client->post('Yes, it works with Laravel 13.', replyTo: $mention->id);
}
</code-snippet>
@endverbatim
## Announcing a release
After a tag is pushed, build the post from the new section of `CHANGELOG.md` and attach the release card GitHub makes for every release:
@verbatim
<code-snippet name="Release announcement" lang="php">
use Darvis\ApiX\XClient;
app(XClient::class)->post(
"darvis/api-x 1.1.0 is out.\n\n• Posts with a GIF\n• Clearer error for a read only token",
'https://opengraph.githubassets.com/1/ArvidDeJong/api-x/releases/tag/v1.1.0',
);
</code-snippet>
@endverbatim
Through MCP the same post is the `post-update` tool with `text` and `image`.
## Handling a refusal
Every failure is an `XException`. When X itself refuses, the message starts with `X refused to create the post` or `X refused to upload the image` and holds the HTTP status and X's reason. A 403 usually means the app has no Write permission or the access token predates it; 402 means no API credit; 429 means wait. Don't retry a 4xx in a loop.
## Testing
@verbatim
<code-snippet name="Fake X in a test" lang="php">
use Illuminate\Support\Facades\Http;
Http::fake([
'api.x.com/2/media/upload' => Http::response(['data' => ['id' => '10']]),
'api.x.com/2/tweets' => Http::response(['data' => ['id' => '20']], 201),
'api.x.com/2/users/me' => Http::response(['data' => ['id' => '1', 'name' => 'You', 'username' => 'you']]),
'api.x.com/2/users/1/mentions*' => Http::response(['data' => [
['id' => '30', 'text' => '@you Nice!', 'author_id' => '2'],
]]),
]);
</code-snippet>
@endverbatim
Use the `array` cache store so the daily limit starts at zero, and `Http::assertNothingSent()` after a dry run.
Use this skill when code or an agent posts to X in an application that has darvis/api-x installed, when it reads the posts that mention the account or answers them, when a post is refused with an XException, when you announce a release on X, or when you write tests around any of this.
Run php artisan x:install. It asks for the four keys, checks them with XClient::account() (one billed read), sets X_SUBSCRIPTION, X_DAILY_LIMIT, X_ALLOW_LINKS and X_DRY_RUN, adds the server to .mcp.json and ends with a dry run. In a script: php artisan x:install --consumer-key=... --consumer-secret=... --access-token=... --access-token-secret=... --verify --mcp --no-interaction.
XClient::post($text, $image = null, $dryRun = false) runs these steps in order and stops at the first failure:
The post text is empty.The post text counts N characters on X; the maximum is M. The limit is 280, or 25,000 when X_SUBSCRIPTION is basic, premium or premium_plus; read it with XConfig::subscription()->maxLength().X_ALLOW_LINKS=true, a URL, www. or a bare domain such as arvid.nl throws the link message.The daily limit of N posts is reached.PostResult with dryRun true and no id.X credentials are missing./2/media/upload (multipart, media_category tweet_image), then create the post at /2/tweets with the media id.XClient::mentions($sinceId = null, $limit = 10) (or x:mentions, or the MCP tool list-mentions) returns the newest posts that mention the account, newest first, without the account's own posts. $limit is kept between 5 and 100. A reply to one of the account's posts has inReplyToId, and inReplyToText when that post is in the x_posts history.
| Step | What happens | Cost |
|---|---|---|
| First call | GET /2/users/me for the account id, then cached per access token |
one billed read, once |
| Every call | GET /2/users/{id}/mentions with since_id and max_results |
every post returned is a billed read |
| Reply | POST /2/tweets with reply.in_reply_to_tweet_id |
like any post; with a link much more |
Pitfalls:
since_id. Reading without it bills the same posts again on another day.X_DAILY_LIMIT and is refused with a link unless X_ALLOW_LINKS=true.X_DRY_RUN does not stop reading; it only concerns posting.@verbatim use Darvis\ApiX\XClient;
$client = app(XClient::class);
foreach ($client->mentions(sinceId: $lastSeenId) as $mention) { // Draft an answer, let the owner approve it, then: $client->post('Yes, it works with Laravel 13.', replyTo: $mention->id); } @endverbatim
After a tag is pushed, build the post from the new section of CHANGELOG.md and attach the release card GitHub makes for every release:
@verbatim use Darvis\ApiX\XClient;
app(XClient::class)->post( "darvis/api-x 1.1.0 is out.\n\n• Posts with a GIF\n• Clearer error for a read only token", 'https://opengraph.githubassets.com/1/ArvidDeJong/api-x/releases/tag/v1.1.0', ); @endverbatim
Through MCP the same post is the post-update tool with text and image.
Every failure is an XException. When X itself refuses, the message starts with X refused to create the post or X refused to upload the image and holds the HTTP status and X's reason. A 403 usually means the app has no Write permission or the access token predates it; 402 means no API credit; 429 means wait. Don't retry a 4xx in a loop.
@verbatim use Illuminate\Support\Facades\Http;
Http::fake([ 'api.x.com/2/media/upload' => Http::response(['data' => ['id' => '10']]), 'api.x.com/2/tweets' => Http::response(['data' => ['id' => '20']], 201), 'api.x.com/2/users/me' => Http::response(['data' => ['id' => '1', 'name' => 'You', 'username' => 'you']]), 'api.x.com/2/users/1/mentions*' => Http::response(['data' => [ ['id' => '30', 'text' => '@you Nice!', 'author_id' => '2'], ]]), ]); @endverbatim
Use the array cache store so the daily limit starts at zero, and Http::assertNothingSent() after a dry run.
How can I help you explore Laravel packages today?