stackshift / assets
Official StackShift Assets SDK for PHP and Laravel
Requires
- php: >=8.2
- ext-curl: *
- ext-json: *
Requires (Dev)
None
Suggests
- illuminate/support: Registers AssetsClient automatically in Laravel applications
Provides
None
Conflicts
None
Replaces
None
README
composer require stackshift/assets
Use StackShift\AssetsClient from Composer. Laravel applications may register it as a singleton and pass UploadedFile::getRealPath() to upload or replace. Mutations require the current revision; resumable-session creation accepts an idempotency key.
AI credits
AI credits are account-scoped. Inspect usage, start a top-up, or select managed billing versus your own provider key from the server-side client:
$balance = $assets->aiCreditBalance(); $usage = $assets->aiCreditUsage(['product' => 'assets', 'limit' => 25]); $quote = $assets->quoteAICredits(1000, 'USD'); $checkout = $assets->checkoutAICredits($quote['id'], $idempotencyKey, 'paystack'); $funding = $assets->setAIFundingMode('assets', 'managed');
Keep the account API key and checkout creation on your server.
Computers
Cloud computers any agent can drive. Use a computers key as the API key.
$computers = new \StackShift\ComputersClient(getenv('STACKSHIFT_API_KEY')); $computer = $computers->create($projectId, 'agent-desktop', ['vcpus' => 4, 'memory_gb' => 8]); $computers->shell($computer['id'], 'npm test', ['cwd' => '/workspace/app']); $computers->writeFile($computer['id'], 'notes.txt', 'hello'); $computers->click($computer['id'], 640, 360, ['screenshot' => true]); $copies = $computers->fork($computer['id'], 3); $cdp = $computers->cdpUrl($computer['id']);
cdpUrl returns a WebSocket URL for Playwright and Puppeteer. Errors throw StackShift\ComputersError with status, errorCode and retryable. Mutations send an Idempotency-Key; pass one to choose it.
ensureProject() returns the project computers go in (id, name, created), making one named "Computers" if the account has none; it needs a user token.
Pick vcpus and memory_gb from the template's sizes (templates() lists them), for example 2 vCPU / 8 GB or 4 vCPU / 16 GB. region picks where it starts; regions($projectId) lists the regions open now.
Tell people what an agent is doing. The dashboard shows the task and next step beside the screen, and askOwner carries the next step on its card.
$computers->setTask($id, 'Book the flight', 'Open the airline site'); $computers->click($id, 640, 360, ['task' => 'Book the flight', 'next_step' => 'Pick a seat']); $computers->askOwner($id, 'Pay $240?', null, ['next_step' => 'Send the receipt']); $computers->clearTask($id); $info = $computers->get($id); echo $info['region_name'], $info['task'], $info['next_step'], $info['fork_duration_ms'] ?? 0;
Read the machine without waking it or adding a step:
$system = $computers->system($id); echo $system['os_name'], $system['kernel'], count($system['services']), count($system['listening']); $windows = $computers->listWindows($id)['windows'];
'internet' => ['mode' => 'none'] blocks all outbound access (open and allowlist are the others). Approvals carry next_step, notified_at and notified_channels, and templates carry os_name.
The paying account's spending limit, defaults for new computers and notification choices:
$settings = $computers->settings($projectId); $computers->saveSettings($projectId, [ 'spend_cap' => ['amount_minor' => 500000, 'currency' => 'NGN'], 'default_approvals' => true, 'notifications' => ['email' => true, 'push' => true], ]); $computers->saveSettings($projectId, ['spend_cap' => null]);
Import a Docker image as a snapshot and follow it:
$import = $computers->importImage([ 'project_id' => $projectId, 'image' => 'node:22', 'name' => 'node-22', 'internet' => ['mode' => 'allowlist', 'allowed_domains' => ['registry.npmjs.org']], 'idle_policy' => ['pause_after_seconds' => 600, 'mode' => 'disk'], 'access' => ['keys' => true], 'logins' => ['github'], 'delete_after_seconds' => 604800, ]); $progress = $computers->setup($import['computer']['id']);
Run a setup on a computer, or at create time with the setup option (exactly one of script, devcontainer or source; source.token reaches the computer once and is not kept). Follow it with setup($id).
$computers->startSetup($id, ['script' => ['content' => "npm ci\nnpm run build\n", 'cwd' => '/workspace/app']]); $computers->startSetup($id, ['devcontainer' => ['workspace_folder' => '/workspace/app']]); $computers->startSetup($id, ['source' => ['repository' => 'https://github.com/acme/app.git', 'revision' => 'main', 'token' => $githubToken]]); $computers->create($projectId, 'coding', ['setup' => ['script' => ['content' => 'make']], 'max_run_seconds' => 3600, 'delete_after_seconds' => 604800]); $computers->update($id, ['archive_after_seconds' => 86400]);
max_run_seconds pauses a computer after it has run that long, archive_after_seconds pauses it to disk that long after it was created, and delete_after_seconds deletes it that long after it was created; 0 clears a limit, otherwise 60 to 2592000.
Run GitHub Actions jobs on your computers with the stackshift-small, stackshift-small-mem (2 vCPU / 8 GB), stackshift-medium, stackshift-medium-mem (4 vCPU / 16 GB), stackshift-large and stackshift-xlarge runner labels:
$overview = $computers->ciOverview($projectId); $labels = $computers->ciLabels($projectId); $jobs = $computers->ciJobs($projectId, 20); $connection = $computers->ciConnect($projectId, $overview['installations'][0]['installation_id']); $computers->ciDisconnect($connection['id']); $circle = $computers->ciConnectCircleCI($projectId, 'acme/stackshift', 'small', $apiToken); $computers->ciDisconnectCircleCI($circle['id']);
Deleting a computer or a snapshot needs confirmation: the SDK sends X-StackShift-Confirm-Delete with the id by default (the server answers 428 confirmation_required without it). Pass the exact name as the last argument to confirm by name: $computers->delete($id, null, 'inbox-helper') and $computers->deleteSnapshot($snapshotId, 'before-upgrade').
Stream a command and feed it input. execStream calls your handler with start, stdout, stderr, exit and error events (return false to stop); sendStdin writes raw bytes to a command that started with stdin_open:
$computers->execStream($id, ['shell' => 'python3 -i', 'stdin_open' => true], function (array $event) use ($computers, $id) { if ($event['event'] === 'start') { $computers->sendStdin($id, $event['data']['id'], "print(6 * 7)\n", true); } if ($event['event'] === 'stdout') { echo $event['data']['data']; } return $event['event'] !== 'exit'; });
Background jobs and the command history:
$history = $computers->history($id, ['limit' => 20]); $last = $computers->command($id); $running = $computers->jobs($id)['items']; $page = $computers->jobLogs($id, $jobId, 0); $computers->followJobLogs($id, $jobId, fn (array $event) => print($event['data']['data'] ?? ''), $page['next_offset']); $done = $computers->waitJob($id, $jobId, 60); $computers->killJob($id, $jobId, 'TERM');
Keep a computer awake while work runs, and read its limits and metrics:
$computers->putActivityLease($id, 'nightly-build', 900); $computers->releaseActivityLease($id, 'nightly-build'); $limits = $computers->limits($id); $metrics = $computers->metrics($id, '2026-11-10T00:00:00Z');
SSH access: createSshToken($id, $ttlSeconds) returns a short-lived token and command, revokeSshTokens($id) revokes them, and sshCertificate($id, $publicKey) signs your public key.
Drive the coding agent on a computer:
$conversation = $computers->promptAgent($id, ['prompt' => 'Fix the failing tests', 'provider' => 'claude']); $computers->followAgentEvents($id, $conversation['id'], function (array $event) { echo $event['text'] ?? ''; return true; }); $computers->steerAgent($id, $conversation['id'], 'Use pnpm, not npm'); $computers->interruptAgent($id, $conversation['id']); $all = $computers->agentConversations($id); $page = $computers->agentEvents($id, $conversation['id'], ['after_seq' => 10]);
Providers are ComputersClient::AGENT_PROVIDERS (claude, codex, gemini, opencode, cursor, copilot, amp, qwen). Attach files, read the result, and try one task several ways:
$providers = $computers->agentProviders($id); $spec = $computers->attachAgentFile($id, './spec.md'); $conversation = $computers->promptAgent($id, ['prompt' => 'Fix the failing tests', 'provider' => 'claude', 'attachments' => [$spec]]); $result = $computers->agentResult($id, $conversation['id'], true); $turns = $computers->agentTurns($id, 20); $fanout = $computers->promptAgent($id, ['prompts' => ['Fix it with a retry', 'Fix it with a lock'], 'provider' => 'codex']); $done = $computers->agentFanout($id, $fanout['id']); $computers->keepAgentFork($id, $fanout['id'], $done['forks'][0]['computer_id']); $all = $computers->agentFanouts($id);
followAgentEvents ends when the conversation ends. Fast file copies have raw calls (startUpload, completeUpload, resumeUpload, startDownload and transfer) plus remove($id, $path, $recursive) and mkdir($id, $path, $parents); there is no high-level uploader in PHP.
An agent can get its own computers key without a person pasting one. These are static and send no Authorization header:
$registration = ComputersClient::registerAgent(['name' => 'my-agent', 'scopes' => ['computers:use']]); echo $registration['verification_uri_complete']; $issued = ComputersClient::waitForAgentKey($registration); $computers = new \StackShift\ComputersClient($issued['key']);
waitForAgentKey polls at the registration's interval, waits five seconds longer after slow_down, keeps going on authorization_pending, and throws ComputersError on access_denied, expired_token or any other code (pass a sleeper as the last argument to control the waiting in tests). The person who owns the account opens verification_uri_complete, or you call $computers->agentClaim($code) and $computers->decideAgentClaim($code, true, ['computers:use'], $projectId) with your own key. createKey takes scopes (computers:read, computers:write, computers:use, computers:delete) as its last argument.
ComputersError also has constants for CLAIM_NOT_FOUND, AUTHORIZATION_PENDING, SLOW_DOWN, EXPIRED_TOKEN, ACCESS_DENIED and INVALID_GRANT.
tests/computers_routes_test.php checks that every route in tests/computers-routes.json (generated by packages/computers-routes.py) has a method that sends it, and fails when a route is added without one.
Prices need no sign-in: ComputersClient::fetchPublicPricing() works with no client or key and sends no Authorization header (pass a base URL or your own transport if you need to). $computers->publicPricing() does the same on an existing client.
Prices include data out per GB (egress_gb, and data_out lines in usage). publicPricing() returns the naira and dollar rates, the free plan's allowance and each plan's running limit.
ComputersError has isLimitExceeded() (429, over your limits or free hours), isCapacityBusy() (429, the free share is busy so retry later) and isSpendCapReached() (402, the account's spending limit is reached), with matching LIMIT_EXCEEDED, CAPACITY_BUSY and SPEND_CAP_REACHED constants.
Partner platforms
Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the partner:manage scope (and mail:manage to run campaigns) from the server-side client:
$domain = $assets->purchasePartnerDomain([ 'domain' => 'merchantshop.com', 'years' => 1, 'merchantRef' => 'm_123', 'registrant' => ['first_name' => 'Ada', 'last_name' => 'Obi', 'email' => 'ada@merchantshop.com', 'phone' => '+2348012345678', 'address' => '1 Marina', 'city' => 'Lagos', 'state' => 'Lagos', 'postal_code' => '101001', 'country' => 'NG'], 'brand' => ['name' => 'Merchant Shop', 'postalAddress' => '1 Marina, Lagos, Nigeria'], 'replies' => ['mode' => 'forward', 'forwardTo' => 'ada@gmail.com'], ], 'order-m_123-1'); $status = $assets->getPartnerDomain($domain['id']);
Related methods are partnerOverview, updatePartnerSettings, listPartnerDomains (with q, attention and website to find the merchants that need you), connectPartnerDomain, transferInPartnerDomain and transferInConnectedPartnerDomain and switchPartnerTransferNameservers for domains the merchant already owns, setPartnerWebsite, removePartnerWebsite, checkPartnerWebsite and listPartnerWebsiteProjects for the storefront, listPartnerMailboxes, createPartnerMailbox, updatePartnerMailbox, createPartnerMailboxPasswordLink, pausePartnerMailbox, resumePartnerMailbox, removePartnerMailbox, createPartnerAlias, updatePartnerAlias and removePartnerAlias for merchant mailboxes, startPartnerMailMigration, getPartnerMailMigration, switchPartnerMailMigration, cancelPartnerMailMigration, retryPartnerMailMigration, retryPartnerMailMigrationAccount and skipPartnerMailMigrationAccount for moving old email in, retryPartnerDomain, getPartnerDiagnosis, restorePartnerDns, transferOutPartnerDomain, resumePartnerBrand, getPartnerComplaints, listPartnerComplaintSources, suppressPartnerNeverOpened, undoPartnerSuppression, partnerUsage, listPartnerRequests and createPartnerRequest. transferOutPartnerDomain needs the partner:transfer scope (or *); partner:manage alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 daily_purchase_limit; staff can raise it). Validation errors are 400, state conflicts are 409.
Computer Teams
A team is several computers working one job, each running its own agent, with a shared folder, a private network and a message board. Same computers key as above.
$teams = new \StackShift\TeamsClient(getenv('STACKSHIFT_API_KEY')); $team = $teams->create([ 'project_id' => $projectId, 'name' => 'checkout-fix', 'job' => 'Refunds fail on partial orders.', 'repository' => ['repository' => 'github.com/northwind/api', 'revision' => 'main'], 'members' => [ ['name' => 'claude-fixer', 'agent' => 'claude-code', 'role' => 'writes the fix'], ['name' => 'codex-reviewer', 'agent' => 'codex'], ], 'stop_rule' => 'done', ]); $teams->sendMessage($team['id'], 'Start with the refund handler.', ['to' => 'claude-fixer']); $page = $teams->waitForMessages($team['id'], 0, 60.0); $teams->streamMessages($team['id'], function (array $message): bool { echo $message['from']['name'], ': ', $message['text'], "\n"; return true; }, $page['next_seq']); $files = $teams->files($team['id']); $teams->pause($team['id']); $teams->delete($team['id']);
create and addMember send an Idempotency-Key; pass one to choose it. update accepts job, stop_rule, spend_limit (null removes it) and ask_before_acting. delete sends the confirmation header for you and removes every member computer and the shared folder. removeMember deletes the member's computer unless $keepComputer is true. listMessages takes after_seq, limit and wait (up to 25 seconds of long-polling), waitForMessages keeps polling until a message arrives or the timeout passes, and streamMessages reads the server-sent stream and reconnects from the last message. network and activity show each member's address, open ports and last terminal lines. Errors throw StackShift\ComputersError with codes such as team_not_found and member_not_found.
Native video, governed DAM and product rendering
Use the space-scoped DAM and video clients for collaborators, metadata schemas, bucket governance, reviewed publications, picker capabilities, model preparation, product rendering, galleries, version-pinned encoding, captions and playback sessions.
$dam = $assets->dam($spaceId); $video = $assets->video($spaceId); $schema = $dam->createMetadataSchema(['name' => 'Products', 'fields' => [[ 'key' => 'product_name', 'label' => 'Product name', 'type' => 'text', 'required' => true, ]]]); $published = $dam->publishMetadataSchema($schema['id'], $schema['revision']); $workspace = $video->workspace($assetId);
Mutations carry the supplied revision in If-Match. Model preparation and rendering carry an idempotency key; render submission also carries the accepted maximum units. Playback renewal and events authenticate with the session credential, not the account key. Keep these clients on your backend and return grants with Cache-Control: private, no-store.
See the Assets SDK guide and its linked feature guides for complete language examples, inputs, permissions, review transitions and browser callbacks.
Assets storage and Cloudinary migration
Use $assets->forAssetSpace($spaceId) for connection, S2 bucket, storage configuration, and migration calls. Run a dry migration and inspect its report before transferring originals.
$platform = $assets->forAssetSpace($spaceId); $preview = $platform->startAssetMigration([ 'source_connection_id' => $connectionId, 'destination_bucket_id' => $bucketId, 'source_prefix' => 'campaigns/2026', 'dry_run' => true, ]); $report = $platform->assetMigrationReport($preview['id']);