---
name: framework-fs-write
description: >-
  Guides the use of `write` from @t4h.framework/fs — the only supported way to
  turn content produced inside a workflow (a JSON string, a CSV, a positional
  return file, a Buffer) into a `Binary` that other primitives accept. Use
  whenever a workflow must upload, send or save content it built itself:
  `sftp.put`/`sftp.append` with generated content, an HTTP request carrying a
  file, persisting a report, or when the user mentions creating/building a
  Binary, converting a string or Buffer to a Binary, "stringToBinary",
  "BufferBinary", or hits `Unsupported binary instance`. A workflow must NEVER
  implement its own `Binary` subclass — this skill explains why and what to do
  instead.
---

# `write` — turn workflow content into a `Binary`

Every file primitive in the framework speaks `Binary`: `sftp.put`,
`sftp.append`, `split`, `join`, `pdf`, HTTP bodies. A `Binary` is a
**reference** to stored content, not the bytes themselves.

`write` is what produces one from content your workflow built:

```typescript
import { write } from '@t4h.framework/fs'

const file = await write(JSON.stringify(state, null, 2), {
  contentType: 'application/json',
})

await sftp.put('/out/state.json', file)
```

Package path: `framework/packages/fs`. Import only from the package root.

---

## Never implement `Binary` yourself

`@t4h.framework/core/no-binary-subclass` rejects a class that extends `Binary`.

```typescript
// ❌ NEVER — this class cannot be persisted
class BufferBinary extends Binary {
  constructor(
    private readonly content: Buffer,
    readonly contentType = '...',
  ) {
    super()
  }

  get contentLength() {
    return this.content.length
  }

  stream() {
    return Readable.from(this.content)
  }
}

await sftp.put('/out/state.json', new BufferBinary(Buffer.from(json)))
```

```typescript
// ✅ ALWAYS
await sftp.put(
  '/out/state.json',
  await write(json, {
    contentType: 'application/json',
  }),
)
```

### Why a custom `Binary` cannot work

A workflow is **durable**: the body replays, and each activity is persisted
before it runs. When you pass a value to a primitive, that value becomes the
activity **input**, and the input is serialized to storage.

A `Binary` holding bytes in memory has nothing to serialize into — the buffer
lives in the process that built it, and the activity may run in another
process, minutes later, after a replay. The runtime therefore has to spool the
content somewhere before the activity can be persisted at all.

Symptoms of doing it by hand:

- `TypeError: Unsupported binary instance` when the activity input is
  serialized, on older runtimes;
- the activity is never persisted, the transaction rolls back, and the message
  ends up in the dead letter queue after the retries — the run stops mid-way
  with no obvious error in the workflow itself;
- the dev server also throws `TypeError: Unsupported binary instance` when it
  stores the activity input. It accepts only the `Binary` that its own claims
  return.

Current runtimes spool an unknown `Binary` to storage instead of failing, so a
hand-rolled class may survive — but it pays a hidden copy, loses the content
type you meant to set, and depends on runtime-version behavior. `write` is the
contract.

---

## API

```typescript
function write(
  content: string | Buffer,
  options?: FileWriteOptions,
): Promise<Binary>
```

| Option        | Type             | Required | Purpose                                                                                                                                                     |
| ------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contentType` | `string`         | No       | Content type stored with the file — `'application/json'`, `'text/csv'`, … Defaults to what the filename extension implies, then `application/octet-stream`. |
| `filename`    | `string`         | No       | Name the file is stored under. Defaults to the activity id.                                                                                                 |
| `encoding`    | `BufferEncoding` | No       | How a **string** `content` is encoded. Defaults to `'utf8'`. Ignored for a `Buffer`, which always travels as base64.                                        |

`write` is a durable activity: on replay the content is **not** written again;
the recorded `Binary` is returned.

---

## When to use it

| Situation                                                                     | Use                                           |
| ----------------------------------------------------------------------------- | --------------------------------------------- |
| Send content the workflow built (JSON, CSV, positional file) over SFTP/HTTP   | `write`                                       |
| Read a small window of an existing file into the workflow                     | [`read`](./read) — see **framework-fs-split** |
| Fan a large file out into one workflow per record                             | `split` — see **framework-fs-split**          |
| Fold a finished batch back into a file                                        | `join` — see **framework-fs-split**           |
| Content already is a `Binary` (HTTP response body, `sftp.get`, `join` result) | pass it straight through                      |

---

## Size matters: keep content bounded

`write` takes content **in workflow memory**, so it is bounded by what the
workflow can reasonably hold — a state file, a report, a return file. It is not
the tool for gigabyte outputs.

To build a large file out of many records, use `join` over a batch: it streams
results one at a time into storage, and nothing but the resulting `Binary`
reaches the workflow.

---

## Patterns

### A) Write then upload

```typescript
const csv = await write(rows.map(toCsvLine).join('\n'), {
  contentType: 'text/csv',
  filename: `report-${input.date}.csv`,
})

await sftp.put(`/out/report-${input.date}.csv`, csv)
```

### B) Write once, use twice

The same `Binary` can be handed to more than one primitive — it is a reference,
so nothing is copied and nothing is re-uploaded:

```typescript
const file = await write(json, { contentType: 'application/json' })

await sftp.put(`${path}.tmp`, file)
await sftp.put(path, file)
```

Note that a hand-rolled `Binary` backed by a single-use stream would break on
the second call — another reason not to build one.

### C) Binary content

```typescript
const pdfBytes: Buffer = await buildPdf()

await sftp.put(
  '/out/invoice.pdf',
  await write(pdfBytes, {
    contentType: 'application/pdf',
  }),
)
```

---

## Testing

`write` goes through `FileSystemClaim.write`, so mock that claim like any
other. See the **framework-workflow-testing** skill for the harness API.

```typescript
import { TestWorkflowEnvironment, mockClaim } from '@t4h.framework/core/testing'
import { FileSystemClaim } from '@t4h.framework/fs'

// replay runs the workflow twice — return a fresh Binary per call
const write = vi
  .fn()
  .mockImplementation(
    async (path, content, contentType) =>
      new MockBinary(
        Buffer.isBuffer(content) ? content : await toBuffer(content),
        contentType,
      ),
  )

await env.execute(workflow, input, {
  claims: [
    mockClaim(FileSystemClaim, { write, read: vi.fn(), slice: vi.fn() }),
  ],
})

expect(write).toHaveBeenCalledWith('report.csv', expect.any(Buffer), 'text/csv')
```

---

## Anti-patterns

- Helper functions like `stringToBinary(value)` that wrap content in a custom
  class — replace the implementation with `write`, keeping the helper name if
  it reads well.
- `write`-ing a value that already is a `Binary`. `write` takes only
  `string | Buffer`, so `tsc` rejects it.
- Building a large file by concatenating records in memory and calling `write`
  once — use `split`/`join` instead.
- Relying on the filename extension to set the content type when the consumer
  cares about it — pass `contentType` explicitly.

---

## Related skills

- `framework-fs` — the `FileSystemClaim` contract and runtime providers.
- `framework-fs-split` — `read`/`split`/`join`: windows, fan-out and fold.
- `framework-sftp` — `sftp.put`/`append`, the most common consumer of `write`.
- `framework-workflow-creation` — the rules a workflow body must follow.
