earl 0.5.2

AI-safe CLI for AI agents
---
title: Streaming
icon: Radio
description: How to use streaming responses in Earl — HTTP SSE, newline-delimited JSON, gRPC server-streaming, and Bash.
---

Most Earl commands complete in a single round-trip: send a request, receive a response, format it. Streaming is for cases where the server sends data progressively — a language model token stream, a log tail, a long-running export. Instead of waiting for the full response, Earl prints output as chunks arrive.

## When to use streaming

Streaming makes sense when:

- The server uses Server-Sent Events (SSE) to push incremental data.
- The response is newline-delimited JSON (NDJSON) — each line is a separate JSON object.
- The server takes long enough that you want to see partial results rather than waiting.
- You're calling a gRPC server-streaming RPC.
- You're running a Bash script that produces output continuously.

For normal JSON APIs that respond in under a second, streaming adds complexity without benefit.

## How streaming decode works

Set `stream = true` in the operation block to enable streaming. Earl then reads the response in chunks and runs the `output` template once per chunk, printing each result immediately.

The `decode` field in the result block controls how each chunk is parsed. It applies **per chunk**, not to the full response:

- `decode = "json"` — parse each chunk as JSON; `result` is the decoded object. Chunks that fail to parse (like a `[DONE]` terminator) are skipped with a warning.
- `decode = "text"` — each chunk is a string. Use this when you need the template to handle non-JSON lines.
- `decode = "auto"` — infer from Content-Type; SSE responses (`text/event-stream`) decode as text.

## HTTP streaming

```hcl
operation {
  protocol = "http"
  method   = "POST"
  url      = "https://api.example.com/stream"
  stream   = true

  auth {
    kind   = "bearer"
    secret = "provider.token"
  }

  body {
    kind  = "json"
    value = { prompt = "{{ args.prompt }}" }
  }
}

result {
  decode = "json"
  output = "{{ result }}"
}
```

With `stream = true`, Earl reads the response body line by line instead of buffering it. The output template runs once per chunk and prints immediately.

## Server-Sent Events (SSE)

SSE responses look like this on the wire:

```
data: {"id":"1","delta":"Hello"}
data: {"id":"2","delta":" world"}
data: [DONE]
```

Earl buffers each SSE event block (up to the blank-line boundary) and strips the `data: ` prefix before decoding. If an event has multiple `data:` lines, they are joined with `\n` into a single chunk. With `decode = "json"`, each event that parses as JSON produces a `result` — non-JSON events like `[DONE]` are skipped automatically.

A practical SSE streaming command:

```hcl
command "stream_completion" {
  title       = "Stream completion"
  summary     = "Stream a chat completion response token by token"
  description = "Call the completions API and print tokens as they arrive."

  annotations {
    mode    = "read"
    secrets = ["openai.api_key"]
  }

  param "prompt" {
    type        = "string"
    required    = true
    description = "The prompt to complete"
  }

  operation {
    protocol = "http"
    method   = "POST"
    url      = "https://api.openai.com/v1/chat/completions"
    stream   = true

    auth {
      kind   = "bearer"
      secret = "openai.api_key"
    }

    body {
      kind = "json"
      value = {
        model    = "gpt-4o"
        stream   = true
        messages = [{
          role    = "user"
          content = "{{ args.prompt }}"
        }]
      }
    }
  }

  result {
    decode = "json"
    output = "{{ result.choices[0].delta.content | default('') }}"
  }
}
```

The `data: ` prefix is stripped before decoding, so `result` is the parsed JSON payload. The `[DONE]` terminator fails JSON parsing and is skipped — no template handling needed.

If you need to handle all lines in the template (including terminators), use `decode = "text"` instead:

```hcl
result {
  decode = "text"
  output = "{% if result != '[DONE]' %}{% set chunk = result | from_json %}{{ chunk.choices[0].delta.content | default('') }}{% endif %}"
}
```

## Newline-delimited JSON (NDJSON)

NDJSON responses send one JSON object per line, with no SSE envelope:

```
{"id":1,"event":"order.created","amount":9900}
{"id":2,"event":"order.updated","amount":9900}
```

Use `decode = "json"` — each line is a complete JSON object:

```hcl
command "tail_events" {
  title       = "Tail events"
  summary     = "Stream events from the webhook log"
  description = "Stream the live event feed as newline-delimited JSON."

  annotations {
    mode    = "read"
    secrets = ["myapp.api_key"]
  }

  operation {
    protocol = "http"
    method   = "GET"
    url      = "https://api.myapp.com/events/stream"
    stream   = true

    auth {
      kind     = "api_key"
      secret   = "myapp.api_key"
      location = "header"
      name     = "X-Api-Key"
    }
  }

  result {
    decode = "json"
    output = "[{{ result.event }}] order {{ result.id }} — {{ (result.amount / 100) | round(2) }}"
  }
}
```

If the server sends blank lines, they fail JSON parsing and are skipped. If you need to handle them explicitly, switch to `decode = "text"` and guard with `{% if result %}` before calling `from_json`.

## gRPC server-streaming

Earl supports server-streaming RPCs — one request, multiple response messages. Client-streaming and bidirectional streaming are not supported.

Set `stream = true` in the operation block. Each gRPC response message is a JSON object, so use `decode = "json"`. The output template runs once per message.

```hcl
command "watch_logs" {
  title       = "Watch logs"
  summary     = "Stream log entries from the logging service"
  description = "Open a server-streaming RPC and print log entries as they arrive."

  annotations {
    mode    = "read"
    secrets = ["logging.api_key"]
  }

  param "service_name" {
    type        = "string"
    required    = true
    description = "Service whose logs to stream"
  }

  param "tail" {
    type        = "integer"
    required    = false
    default     = 100
    description = "Number of recent lines to include at stream start"
  }

  operation {
    protocol = "grpc"
    url      = "https://logging.example.com"
    stream   = true

    auth {
      kind   = "bearer"
      secret = "logging.api_key"
    }

    grpc {
      service = "logging.v1.LogService"
      method  = "WatchLogs"
      body = {
        service_name = "{{ args.service_name }}"
        tail         = "{{ args.tail }}"
      }
    }
  }

  result {
    decode = "json"
    output = "[{{ result.timestamp }}] {{ result.severity }}: {{ result.message }}"
  }
}
```

Each gRPC response message arrives as a separate `result` value. With `stream = true` and a server-streaming RPC, the connection stays open until the server closes it.

If the gRPC server doesn't expose reflection (or only supports v1alpha), provide a descriptor set:

```hcl
grpc {
  service             = "logging.v1.LogService"
  method              = "WatchLogs"
  descriptor_set_file = "logging.pb"
  body = {
    service_name = "{{ args.service_name }}"
    tail         = "{{ args.tail }}"
  }
}
```

## Bash streaming

Bash operations support streaming stdout line by line. Set `stream = true` and use `decode = "text"` — each line becomes `result`.

```hcl
command "tail_file" {
  title       = "Tail file"
  summary     = "Stream lines from a file"
  description = "Print lines from the given file as they are written."

  annotations {
    mode = "read"
  }

  param "path" {
    type        = "string"
    required    = true
    description = "Path to the file to tail"
  }

  operation {
    protocol = "bash"
    stream   = true

    bash {
      script = "tail -f {{ args.path }}"
    }
  }

  result {
    decode = "text"
    output = "{{ result }}"
  }
}
```

Each line written to stdout becomes a separate chunk. The template runs once per line and prints immediately.

## Streaming limitations

- Client-streaming and bidirectional streaming are not supported for gRPC. Earl sends one request and receives zero or more response messages.
- SQL does not support streaming. The `stream` flag has no effect on SQL operations.
- GraphQL does not support streaming.
- The output template runs per chunk. Carrying state between chunks (counting, accumulation) is not possible in the template — post-process the output if needed.

## Supported protocols

Streaming is available on [HTTP](/docs/http) (Server-Sent Events and NDJSON), [gRPC](/docs/grpc) (server-streaming RPCs), and [Bash](/docs/bash) (stdout line-by-line). SQL and GraphQL do not support streaming.