> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getdialed.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Files

> Turn a set of records into a stored file, move it in and out of a bucket you own, and pass it between steps as a reference — the bytes never travel through the flow

A **file** is bytes the platform stores for you, and a flow refers to it by an **id**. A step writes a set of records out as a file and hands back `file_id`, `size_bytes`, `sha256` and a row count. The next step binds that id. The file's contents never enter the flow unless a step is asked for them explicitly.

Files arrive from three directions: a step writes one, a step fetches one out of a bucket you own, or **a human sends one back** — the attachments on a reply that resumes a [waiting flow](/concepts/human-in-the-loop) are stored as files automatically, and are addressable by the same `file_id` every step on this page takes.

```json theme={null}
{
  "task_id": "task_write",
  "task_name": "Write the report out as NDJSON",
  "platform_id": "getdialed",
  "service_id": "getdialed__files",
  "action_id": "getdialed__files__create_file",
  "parameters": {
    "record_set_id": "{{ step_report.output.record_set_id }}",
    "format": "ndjson",
    "filename": "daily-activity.ndjson"
  }
}
```

## How a file travels

**By reference, never by value.** This is the same promise [record transforms](/concepts/record-transforms#how-the-records-travel) make about rows, one layer out: a step is given an *identifier*, reads or writes the bytes as it runs, and hands back an identifier. A flow carries an id, a size and a checksum — not the file.

That is what makes a file of any size workable. A file holding two records and a file holding twenty million cost the flow exactly the same, because what moves between steps is the same handful of values either way. It is also why the steps chain the way they do: one step's `file_id` binds into the next step's input like any other step output.

```json theme={null}
{
  "steps": [
    {
      "step_id": "step_write",
      "tasks": [
        {
          "task_id": "task_write",
          "task_name": "Write the set out",
          "platform_id": "getdialed",
          "service_id": "getdialed__files",
          "action_id": "getdialed__files__create_file",
          "parameters": {
            "record_set_id": "{{ input.record_set_id }}",
            "format": "ndjson"
          }
        }
      ]
    },
    {
      "step_id": "step_upload",
      "tasks": [
        {
          "task_id": "task_upload",
          "task_name": "Put it in the warehouse bucket",
          "platform_id": "aws",
          "service_id": "aws__s3",
          "action_id": "aws__s3__put",
          "connection_id": "cred_e5f6g7h8",
          "parameters": {
            "file_id": "{{ step_write.output.file_id }}",
            "key": "staging/2026/08/activity.ndjson"
          }
        }
      ]
    }
  ]
}
```

<Note>
  **There is no way to type a file's contents into a step.** That direction is closed deliberately: a step that accepted either a reference or a block of bytes would behave differently depending on which one it was handed, and the size a flow can move would silently become the size a single value can be. The reference is the only way in.

  Reading contents **out** is a different question, and it has one deliberate answer: [`getdialed__files__read_text`](#read-a-file-as-text), a step whose whole job is to say "yes, this file, as text, under a stated cap". It is a step you ask for rather than a shape any step might return, which keeps the size question in one place.
</Note>

## Choosing a format

`create_file` writes one of three shapes, and `format` is required — there is no default to be surprised by.

| `format`     | What it writes                                                                     | Pick it when                                                                                                                            |
| ------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ndjson`     | One JSON record per line.                                                          | Loading into a warehouse. **This is the recommended shape for Snowflake with Snowpipe**, which reads a file like this a line at a time. |
| `csv`        | A spreadsheet-style file with one column for every column the record set declares. | Somebody opens it, or a tool wants columns.                                                                                             |
| `json_array` | The whole set as a single JSON list.                                               | A small file a person reads by hand. It is the worst shape for a large one — the reader has to hold the whole list.                     |

A CSV's header comes from the columns the record set **declares**, not from the rows that happen to be in it. A column that is empty in every record still gets its column, so the file keeps the shape the set promised.

### Compression

Set `compress` to `gzip` to store the file compressed; leave it empty for a plain file. A compressed file's name ends `.gz`, which is how Snowflake recognises it automatically when it loads the file — there is nothing else to configure at that end.

Compression happens as the file is written rather than afterwards, so it costs nothing in memory however large the set is.

<Note>
  `content_type` describes the **format** and does not change when a file is compressed — an NDJSON file is still NDJSON. The name ending `.gz` is what says it is compressed, and `compressed` on the step's output says so directly. `size_bytes` is the size **as stored**, so it is the compressed size for a compressed file.
</Note>

## Labelling a file

`tags` is a set of names and values you choose, stored with the file: `{"team": "ops"}`, `{"source": "nightly-sync"}`, whatever you will want to recognise later. Up to **32** labels, each name up to **64** characters and each value up to **256**.

<Note>
  **Nothing searches tags yet, and they ship anyway.** There is no tag filter, no file listing and no browser to filter with — a tag today is read back by `getdialed__files__get_file_metadata`, and a flow can branch on it. They ship now because a file created before tagging existed would be permanently untagged: a search added later can only find what was already labelled, and no retrofit can invent a label for a file whose purpose nobody recorded at the time.
</Note>

`filename` is a **label only**. It does not decide where the file is stored and it is not part of the file's address, so a name with slashes in it makes no folders and a name that repeats an earlier one overwrites nothing. Every file has its own reference, and that reference is what every later step uses.

## How long a file is kept

**Files are kept until you delete them.** There is no default expiry and nothing quietly reclaims a file you stopped looking at.

For a file you only need temporarily, `expires_in_days` takes one of **`1`, `7`, `30` or `90`**, and the storage lifecycle removes the file when its time is up. The set is closed rather than free-form because those are the lengths the storage layer can actually enforce — offering an arbitrary number would be promising a schedule nothing implements.

Leaving `expires_in_days` empty is the usual choice. Delete a file with `getdialed__files__delete_file` when you are done with it.

<Note>
  **Inbound reply attachments are the one exception: they are kept for 30 days.** A file that arrives on a reply was not created by a step, so nothing set an expiry for it — and it arrived on a path anybody holding the reply address can reach, where unbounded storage is not a thing to offer. The horizon is fixed rather than configurable. A flow that needs a copy for longer should write one out with `create_file` while the reference is still live.
</Note>

## The steps

Seven steps ship. Four are first-party and need **no credential**; three reach a bucket you own and select an [AWS credential](/concepts/credentials#aws-access-key).

| Action                                | Credential | What it does                                                 |
| ------------------------------------- | ---------- | ------------------------------------------------------------ |
| `getdialed__files__create_file`       | none       | Writes a record set out as a file and returns its reference. |
| `getdialed__files__read_text`         | none       | Reads a stored file's contents as text, under a size cap.    |
| `getdialed__files__get_file_metadata` | none       | Reads a file's details without reading its contents.         |
| `getdialed__files__delete_file`       | none       | Deletes a stored file's contents.                            |
| `aws__s3__put`                        | AWS        | Uploads a stored file into your bucket.                      |
| `aws__s3__get`                        | AWS        | Fetches an object out of your bucket **as a stored file**.   |
| `aws__s3__delete`                     | AWS        | Removes an object from your bucket.                          |

`GET /catalog/actions/{action_id}` publishes the complete schema for any of them.

### Create a file

`getdialed__files__create_file` takes a sealed [record set](/concepts/record-transforms) and writes it out.

| Parameter         | Required | What it is                                                                                                                   |
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `record_set_id`   | yes      | The set to write. Its records are read as the step runs, so a set of any size is written without being copied into the flow. |
| `format`          | yes      | `csv`, `ndjson` or `json_array`.                                                                                             |
| `filename`        |          | A name recorded so you can recognise the file later. A label only.                                                           |
| `compress`        |          | `gzip`, or empty for a plain file.                                                                                           |
| `expires_in_days` |          | `1`, `7`, `30` or `90`. Empty means kept until deleted.                                                                      |
| `tags`            |          | Your own labels for the file.                                                                                                |

It reports:

| Output         | What it holds                                                                                                                                  |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `file_id`      | The file's reference — what every later step binds.                                                                                            |
| `size_bytes`   | How large the stored file is, after compression when it is compressed.                                                                         |
| `sha256`       | The checksum over the bytes as stored. Anything that downloads the file can compute the same value and confirm it received the file unchanged. |
| `row_count`    | How many records were written, so it reconciles against the source set's own count.                                                            |
| `content_type` | The media type — for example `text/csv`.                                                                                                       |
| `compressed`   | Whether the stored file is compressed.                                                                                                         |

The step also records which step of which run produced the file. You do not supply that.

### Read a file as text

`getdialed__files__read_text` takes a `file_id` and hands back the file's contents. It is platform-neutral: it does not care where the bytes came from, so the same step serves a file an earlier step created, a file fetched out of your bucket, and an attachment on a reply that resumed a waiting flow.

```json theme={null}
{
  "task_id": "task_read",
  "task_name": "Read the attached CSV as text",
  "platform_id": "getdialed",
  "service_id": "getdialed__files",
  "action_id": "getdialed__files__read_text",
  "parameters": {
    "file_id": "{{ step_ask.output.attachments[0].file_id }}"
  }
}
```

It reports:

| Output         | What it holds                                                                                                                                                                                      |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text`         | The contents. A byte-order mark at the very start is removed, because left in place it becomes part of the first column's name and every later step naming that column would quietly read nothing. |
| `size_bytes`   | How many bytes were read. Zero means the file held nothing — worth branching on before a later step tries to parse it.                                                                             |
| `sha256`       | The checksum over the bytes as stored.                                                                                                                                                             |
| `content_type` | The type recorded when the file was stored. It is a label, and it is **not** what the step checked.                                                                                                |
| `filename`     | The name recorded when the file was stored.                                                                                                                                                        |
| `truncated`    | Always `false`. See below.                                                                                                                                                                         |

Three limits, and each one is a refusal rather than a partial answer:

| Refused                                 | Because                                                                                                          |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| A file over **512 KB**                  | The text has to travel through the flow as a value, and a value has a hard ceiling.                              |
| A file that is not valid **UTF-8**      | Reading binary as text produces replacement characters that look exactly like a successful read.                 |
| A reference that names no file of yours | A file belongs to the account whose flow is running, and a reference to somebody else's simply does not resolve. |

<Warning>
  **This step refuses a file that is too large — it never returns part of one.** That is why `truncated` is always `false`, and why the field exists at all: so a flow written against it today keeps working if a future step ever does read files in parts.

  The reason is worth the sentence. A truncated CSV **parses cleanly**. It produces a real record set id, a plausible row count and a step that succeeded — and the rows that fell off the end are the ones nobody ever calls again. Failing loudly is the only honest option here.
</Warning>

**For a CSV, you usually do not want this step at all.** `getdialed__records__parse_csv` accepts a `file_id` directly and reads the file inside the step, so the contents never travel through the flow and its own 1 MB ceiling applies instead of this one. Reach for `read_text` when the flow needs the *text* — to branch on it, to put it in a message, to send it somewhere — rather than the records.

### Read a file's details

`getdialed__files__get_file_metadata` takes a `file_id` and answers with `filename`, `size_bytes`, `content_type`, `sha256`, `row_count`, `created_at`, `tags`, `compressed` and `status`. **The contents are not read**, so this costs the same for a file of any size.

Use it to decide what a flow should do next: skip an upload when a file turned out empty (`size_bytes` is `0`), route on a label you attached when the file was created, or confirm a file is still there before a later step uses it.

`status` is `available` or `deleted`. `row_count` is empty for a file fetched out of a bucket — the platform never read that file's contents, so it has no count to report.

### Delete a file

`getdialed__files__delete_file` removes the file's contents. **The file's record is kept and marked deleted**, so you can still see later that the file existed and when it went away, rather than finding nothing at all — which is also why `get_file_metadata` still answers for a deleted file instead of failing.

Running it twice is safe: `deleted` says whether **this** run removed the file and `already_deleted` says it was gone before the step ran. Both are successes. A reference that names no file of yours is a failure, because reporting a successful deletion for a file that never existed would make the step's own answer meaningless.

Deleting a file does not affect anything a previous step produced from it.

### Put an object in a bucket

`aws__s3__put` streams a stored file into a bucket you own.

| Parameter      | Required | What it is                                                                                                              |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `file_id`      | yes      | The stored file to upload.                                                                                              |
| `key`          | yes      | The full path within the bucket. You write it out yourself.                                                             |
| `bucket`       |          | The destination bucket. Empty uses the default bucket recorded on the credential.                                       |
| `content_type` |          | The media type to label the object with. Empty uses the type recorded on the stored file, which is almost always right. |

It reports `written` and `size_bytes`. Nothing about the destination comes back — you supplied it, and repeating it would store a copy of it in the run history.

<Warning>
  **An object already at that key is replaced.** That is what an upload to S3 does, and the step does not pretend otherwise. Write the key deliberately: a warehouse loader watches a particular prefix, so the path is part of the integration rather than a detail to be generated.
</Warning>

Object keys are checked before anything is sent. A key may not begin with a slash, may not contain a `.` or `..` step, may not contain control characters, and may be at most 1024 bytes including any prefix. A refusal names the rule rather than repeating the path.

### Get an object out of a bucket

`aws__s3__get` takes `key` (and optionally `bucket`) and returns `file_id`, `size_bytes`, `content_type` and `sha256`.

**It hands back a file reference, never the object's contents.** The object is streamed into the platform's own file storage and the next step receives an identifier for it. There is no option to get the contents back as text instead, for the reason in the note above: a step that sometimes returned a reference and sometimes returned data would behave differently depending on the object it was pointed at. The last part of the key becomes the stored file's name.

<Note>
  **A missing object and an unreachable one fail identically.** S3 itself refuses to tell an unauthorized caller which of the two it is — that is how it stops an outsider probing a bucket for what it contains — and neither does this step. See [why the IAM policy grants `s3:ListBucket`](/guides/store-a-report-in-s3#the-iam-policy) for the other half of that story.
</Note>

### Delete an object

`aws__s3__delete` takes `key` (and optionally `bucket`) and reports `deleted`.

**S3 reports success for a path that was never there, and so does this step.** It tells you the delete was carried out, not that something was there to delete. There is no way to learn the difference, and that is deliberate on S3's part as well as ours — a step that reported the difference would let a flow probe a bucket for what it contains. Running it twice is safe and reports the same thing both times.

## Where a file lives

Two storage locations are in play, and they are owned by different people.

|                             | Owned by  | Written by                | What lands there                                                                                                                                             |
| --------------------------- | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **The platform file store** | GetDialed | The platform              | Everything `create_file` writes, everything `aws__s3__get` fetches, and every attachment on a reply that resumes a flow. This is what a `file_id` points at. |
| **Your bucket**             | You       | The credential you supply | Whatever `aws__s3__put` uploads.                                                                                                                             |

The split matters for two reasons. A `file_id` keeps working even if you revoke the AWS credential tomorrow, because nothing about the platform file store depends on it. And an object in your bucket is **yours** — GetDialed's own permissions do not reach it, and a step only touches it with the access key you supplied for that purpose.

## What is not here yet

Named plainly, because finding a gap by hitting it is worse than reading about it:

* **No file listing and no file browser.** You can read a file you hold the reference to. There is no way to ask "what files do I have", and nothing filters on tags yet.
* **No download URL.** There is no API endpoint that hands you a link to a stored file. To get a file out today, `aws__s3__put` it into a bucket you own.
* **No file-arrival trigger.** A file landing somewhere does not start a flow. Flows still start from a [trigger](/concepts/triggers) — manual, scheduled, webhook or event.
* **Only CSV comes back into records.** `getdialed__records__parse_csv` reads a stored CSV into a record set; a JSON or NDJSON file has no equivalent yet, and neither does a file larger than that step's 1 MB ceiling.
* **No renaming, converting or editing a stored file.** `filename` is fixed when the file is written.
* **Amazon S3 only, plus S3-compatible services.** Other object stores are reached only if they speak the S3 API — see the [S3-compatible endpoint](/concepts/credentials#s3-compatible-storage) setting.
* **One AWS credential shape: an access-key pair.** Cross-account IAM roles are not supported.

## Next steps

<CardGroup cols={2}>
  <Card title="Store a report in S3" icon="cloud-arrow-up" href="/guides/store-a-report-in-s3">
    The end-to-end walkthrough: run a report, write it as NDJSON, land it where Snowpipe picks it up.
  </Card>

  <Card title="Credentials" icon="key" href="/concepts/credentials#aws-access-key">
    Create the AWS credential the S3 steps select, and the IAM policy behind it.
  </Card>

  <Card title="Record transforms" icon="wand-magic-sparkles" href="/concepts/record-transforms">
    Reshape a set of records before you write it out.
  </Card>

  <Card title="Integration catalog" icon="grid" href="/concepts/catalog">
    The full published schema for every step on this page.
  </Card>
</CardGroup>
