# Files and one-off tasks for agents

Operate a deployed Service's filesystem and run bounded commands through the API. This page is written for an AI agent. It extends the deployment protocol at `https://app.tokay.io/llms.txt` and assumes you hold a working credential and use the Relay node IDs GraphQL returns. Everything here is an escape hatch. The happy path needs none of it, so act on an explicit user request or a named readiness action, not on your own initiative.

## The file model

- Every Service has two file scopes. `SAVED` is durable app owned storage. It survives deploys and restarts, mounts into live containers and one-off tasks, and is the only place durable data belongs. `CURRENT` is the filesystem of the live container right now. It is read only evidence and disappears when the instance is replaced.
- `service.fileRoots` is the map. Each root is an absolute container path such as `/tokay/data` or `/app/uploads`. Every path you pass on this page must be an absolute path inside one of these roots. `/tokay/data` always exists.
- SAVED roots come from declared storage paths. Tokay detects natural write directories such as `./uploads` during analysis and declares them for you (author `TOKAY`, state `STICKY`). You can declare more.
- Listings validate paths immediately. Ticket minting validates nothing. A wrong path in a download or upload ticket fails at redemption, not at mint, so verify paths against a listing first.
- A one-off task is the only exec path. There is no SSH. `runServiceTask` runs your command in the Service's current image with its full runtime environment and SAVED storage mounted, and records the output durably.

## What to call

| To | Use |
|---|---|
| See which directories are durable | `service.fileRoots` |
| List files in a scope | `service.files(scope:, path:)` |
| Download files | `createServiceFileDownloadTicket`, then GET the URL |
| Upload into durable storage | `createServiceFileUploadTicket`, then PUT the bytes |
| Save a live file before it disappears | `copyCurrentFileToSaved` |
| Run a bounded command | `runServiceTask`, then poll `releaseExecution` |
| Inspect declared durable directories | `service.serviceStoragePaths` |
| Make another directory durable | `addServiceStoragePath` |

## Find the roots, then list inside them

```
query Files($id: ID!) {
  service(id: $id) {
    fileRoots { path scope durability taskVisible writable survivesDeploy }
    files(scope: SAVED, path: "/app/uploads", first: 200) {
      entries { name path type sizeBytes updatedAt }
      currentInstanceRef
    }
  }
}
```

Query `fileRoots` first and list inside a root. Listing `path: "/"` fails with "Path is not inside Saved service files" unless `/` is itself a root, which is true only for `CURRENT`. Entry `type` is `FILE`, `DIRECTORY`, or `SYMLINK`. A `CURRENT` listing needs a running container and returns `currentInstanceRef`. Save that ref when you plan to rescue.

## Download files

```
mutation DownloadTicket($service: ID!, $paths: [String!]!) {
  createServiceFileDownloadTicket(input: {
    service: $service,
    serviceFileScope: SAVED,
    paths: $paths,
    serviceFileArchiveFormat: RAW
  }) {
    serviceFileDownloadTicket { downloadUrl expiresAt }
  }
}
```

Then `GET {API_ORIGIN}{downloadUrl}` with no Authorization header. The ticket is the authorization. Tickets expire quickly and work once. Use `RAW` for one file and `TAR_GZ` for several paths or a directory. Minting succeeds even for paths outside SAVED. The GET then returns HTTP 400 with `{"error": ..., "code": "SERVICE_FILE_PATH_NOT_SAVED"}`. For a `CURRENT` download pass `serviceFileScope: CURRENT` plus `expectedCurrentInstanceRef` from a listing.

## Upload into durable storage

```
mutation UploadTicket($service: ID!) {
  createServiceFileUploadTicket(input: {
    service: $service,
    destinationPath: "/app/uploads/report.csv",
    overwrite: true,
    expectedSizeBytes: "1024"
  }) {
    serviceFileUploadTicket { uploadUrl maxBytes expiresAt }
  }
}
```

Then `PUT {API_ORIGIN}{uploadUrl}` with the raw bytes as the body. Success returns `{"ok": true, "bytesReceived": N}`. `expectedSizeBytes` is a `BigInt` scalar, so send it as a string in JSON variables. `destinationPath` must be absolute inside a SAVED root. A wrong destination fails at PUT with code `SERVICE_FILE_DESTINATION_NOT_SAVED`. The running app sees the file immediately, with no restart. Respect the returned `maxBytes`.

## Rescue a live file

If the app wrote something important outside durable storage, copy it into SAVED before the instance changes.

```
mutation Rescue($service: ID!, $ref: String!) {
  copyCurrentFileToSaved(input: {
    service: $service,
    sourcePath: "/app/report.json",
    destinationPath: "/app/uploads/report.json",
    expectedCurrentInstanceRef: $ref,
    overwrite: true
  }) {
    currentFileCopy { id currentFileCopyStatus errorCode errorMessage }
  }
}
```

The copy is asynchronous. Poll `currentFileCopy(id:)` until `currentFileCopyStatus` reaches `SUCCEEDED` or `FAILED` (`PENDING` and `RUNNING` come first). `expectedCurrentInstanceRef` must come from a `CURRENT` listing. If the container was replaced between your read and the copy, the guard fails the copy instead of copying a different instance's file. Re-list and retry. After a rescue, declare the source directory durable so future writes survive on their own.

## Run a one-off task

```
mutation Task($service: ID!, $command: [String!]!) {
  runServiceTask(input: { service: $service, command: $command }) {
    releaseExecution { id }
  }
}
```

`command` is argv, not a shell string. Wrap pipes and chains in `["sh", "-c", "..."]`. The working directory defaults to the service root, so relative paths like `uploads` resolve against `/app`. Pass `workingDirectory` to change it. Poll the returned row:

```
query TaskResult($id: ID!) {
  releaseExecution(id: $id) { releaseExecutionState exitCode logTail }
}
```

`releaseExecutionState` reaches `SUCCEEDED` or `FAILED`. Simple commands finish in a few seconds. The task runs the Service's current image with its full runtime environment (secrets, database URLs) and SAVED storage mounted, and the run is attributed to your identity and credential. The command must exit. A server or a watch loop belongs in a Service.

Tasks are the right tool for data fixes. The managed database query endpoint is read only, but a task's framework client holds real credentials from the environment. Schema changes should still ship as migrations with a deploy, which adds rehearsal, snapshots, and confirmation for destructive changes.

## Durable directories

```
query StoragePaths($id: ID!) {
  service(id: $id) {
    serviceStoragePaths(first: 20) {
      nodes { id storagePath serviceStoragePathState serviceStoragePathAuthor }
    }
  }
}
```

`storagePath` is relative to the service root, so `uploads` mounts at `/app/uploads`. States are `PENDING` (recorded, mounts after clean build evidence or user confirmation), `STICKY` (mounted and durable), and `DETACHED` (intentionally unmounted, row kept as a tombstone, data preserved). Check the existing list before declaring anything. Tokay usually detected the app's write directories already, so add one only when the user asks or the app demonstrably writes somewhere undeclared, with `addServiceStoragePath(input: { service: $service, storagePath: "data" })`. Detach and delete only when the user asks. `detachServiceStoragePath` stops mounting but keeps the stored data, `reattachServiceStoragePath` reverses it, and destroying data is the separate `deleteServiceStoragePathData`. New code should skip all of this and write under `TOKAY_DATA_DIR`.

## Error shapes

| You see | It means | Do this |
|---|---|---|
| GraphQL error "Path is not inside Saved service files" | Listing or mutation path is outside the declared roots | Query `fileRoots`, use an absolute path inside a root |
| HTTP 400 `{"code": "SERVICE_FILE_PATH_NOT_SAVED"}` on ticket GET | The download ticket was minted with a bad path | Re-list, mint a new ticket with the listed path |
| HTTP 400 `{"code": "SERVICE_FILE_DESTINATION_NOT_SAVED"}` on PUT | Upload destination is outside SAVED | Target an absolute path inside a SAVED root |
| `currentFileCopyStatus: FAILED` with `errorCode` | Rescue failed, often a replaced instance or bad destination | Read `errorCode` and `errorMessage`, re-list `CURRENT`, retry with the fresh `currentInstanceRef` |
| GraphQL error "Not authorized for ACTION on TYPE id" | Your credential lacks that permission on that resource | Report the missing grant to the user instead of retrying |
