Skip to main content

README.md

# TemporalEx

Ergonomic [Temporal](https://temporal.io) client SDK for Elixir.

Provides a high-level, protobuf-free API for interacting with Temporal workflow services. Work with plain Elixir terms — maps, keyword lists, strings — without constructing protobuf structs.

## Installation

Add `temporal_ex` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:temporal_ex, "~> 0.1.0"}
  ]
end
```

## Quick Start

```elixir
# Start a client (as part of your supervision tree)
{:ok, client} = TemporalEx.connect(
  target: "localhost:7233",
  namespace: "default"
)

# Start a workflow
{:ok, handle} = TemporalEx.start_workflow(client, "MyWorkflow", [%{key: "value"}],
  id: "my-workflow-123",
  task_queue: "my-task-queue"
)

# Interact with the workflow
:ok = TemporalEx.WorkflowHandle.signal(handle, "my-signal", [%{data: 1}])
{:ok, description} = TemporalEx.WorkflowHandle.describe(handle)
{:ok, result} = TemporalEx.WorkflowHandle.result(handle)
```

## Supervision Tree

For production use, start `TemporalEx.Client` under your application supervisor:

```elixir
children = [
  {TemporalEx.Client,
    name: :temporal_client,
    target: "localhost:7233",
    namespace: "default"
  }
]

Supervisor.start_link(children, strategy: :one_for_one)
```

Then reference the client by name:

```elixir
handle = TemporalEx.get_workflow_handle(:temporal_client, "my-workflow-123")
{:ok, desc} = TemporalEx.WorkflowHandle.describe(handle)
```

## Connection Options

| Option | Default | Description |
|--------|---------|-------------|
| `:target` | `"localhost:7233"` | Temporal server address |
| `:namespace` | `"default"` | Default namespace |
| `:api_key` | `nil` | API key or Bearer token |
| `:tls` | `%{}` | TLS/mTLS config (see below) |
| `:name` | `nil` | GenServer registration name |
| `:call_timeout` | `5000` | Default RPC timeout (ms) |
| `:connect_retry` | `0` | gRPC connection retries |
| `:data_converter` | `TemporalEx.DataConverter.Json` | Custom data converter module |
| `:identity` | auto | Client identity string |

### TLS / mTLS

For Temporal Cloud or self-hosted TLS:

```elixir
{TemporalEx.Client,
  target: "my-ns.tmprl.cloud:7233",
  namespace: "my-ns",
  api_key: "my-api-key",
  tls: %{
    client_cert_pem_b64: System.get_env("TEMPORAL_CLIENT_CERT"),
    client_key_pem_b64: System.get_env("TEMPORAL_CLIENT_KEY"),
    ca_cert_file: "/path/to/ca.pem"  # optional
  }
}
```

Temporal Cloud domains (`.tmprl.cloud`, `.api.temporal.io`) automatically use HTTPS.

## API

### Workflow Operations

```elixir
# Start a workflow
{:ok, handle} = TemporalEx.start_workflow(client, "WorkflowType", args, opts)

# Start with an initial signal (atomic)
{:ok, handle} = TemporalEx.signal_with_start(client, "WorkflowType", args, "signal", signal_args, opts)

# Get a handle to an existing workflow
handle = TemporalEx.get_workflow_handle(client, "workflow-id")
handle = TemporalEx.get_workflow_handle(client, "workflow-id", "run-id")
```

### WorkflowHandle Operations

```elixir
{:ok, description} = TemporalEx.WorkflowHandle.describe(handle)
:ok              = TemporalEx.WorkflowHandle.signal(handle, "signal-name", [args])
{:ok, result}    = TemporalEx.WorkflowHandle.query(handle, "query-type", [args])
:ok              = TemporalEx.WorkflowHandle.cancel(handle)
:ok              = TemporalEx.WorkflowHandle.terminate(handle, reason: "reason")
:ok              = TemporalEx.WorkflowHandle.delete(handle)
{:ok, history}   = TemporalEx.WorkflowHandle.get_history(handle)
{:ok, result}    = TemporalEx.WorkflowHandle.result(handle)
{:ok, reset}     = TemporalEx.WorkflowHandle.reset(handle, opts)
```

### Schedule Operations

Temporal Schedules are server-side crons — Temporal starts workflow executions on the configured interval. The worker doesn't need to be running when the schedule is created; it just needs to be available to pick up workflow tasks from the queue.

```elixir
# Create a schedule that runs every 60 seconds
{:ok, handle} = TemporalEx.create_schedule(client, "my-schedule",
  spec: [intervals: [[every: 60]]],
  action: [
    workflow_type: "MyWorkflow",
    workflow_id: "my-workflow",
    task_queue: "my-queue",
    args: [%{key: "value"}]
  ],
  policies: [overlap_policy: :skip]
)

# Get a handle to an existing schedule
handle = TemporalEx.get_schedule_handle(client, "my-schedule")

# List all schedules
{:ok, schedules, next_token} = TemporalEx.list_schedules(client)
```

### ScheduleHandle Operations

```elixir
{:ok, description} = TemporalEx.ScheduleHandle.describe(handle)
:ok              = TemporalEx.ScheduleHandle.pause(handle, note: "maintenance")
:ok              = TemporalEx.ScheduleHandle.unpause(handle, note: "back online")
:ok              = TemporalEx.ScheduleHandle.trigger(handle)
:ok              = TemporalEx.ScheduleHandle.update(handle, schedule: [...])
:ok              = TemporalEx.ScheduleHandle.delete(handle)
```

#### Schedule Spec Options

```elixir
# Interval-based (every N seconds, with optional offset)
spec: [intervals: [[every: 60], [every: 300, offset: 10]]]

# Calendar-based
spec: [calendars: [[hour: "8", minute: "30", day_of_week: "MON-FRI"]]]

# Cron expressions
spec: [cron_expressions: ["0 */5 * * *"]]

# With timezone and jitter
spec: [intervals: [[every: 60]], timezone: "America/Chicago", jitter: 5]
```

#### Overlap Policies

| Policy | Description |
|--------|-------------|
| `:skip` | Skip if previous is still running |
| `:buffer_one` | Buffer one execution |
| `:buffer_all` | Buffer all executions |
| `:cancel_other` | Cancel the running execution |
| `:terminate_other` | Terminate the running execution |
| `:allow_all` | Allow concurrent executions |

### Visibility

```elixir
{:ok, workflows, next_token} = TemporalEx.list_workflows(client, "WorkflowType = 'MyWorkflow'")
{:ok, count} = TemporalEx.count_workflows(client, "WorkflowType = 'MyWorkflow'")
```

### System

```elixir
{:ok, info} = TemporalEx.get_system_info(client)
```

## Error Handling

All errors are returned as typed structs:

- `TemporalEx.Error.WorkflowAlreadyStarted`
- `TemporalEx.Error.WorkflowNotFound`
- `TemporalEx.Error.ScheduleAlreadyExists`
- `TemporalEx.Error.ScheduleNotFound`
- `TemporalEx.Error.NamespaceNotFound`
- `TemporalEx.Error.QueryFailed`
- `TemporalEx.Error.RPCError` (catch-all)

```elixir
case TemporalEx.start_workflow(client, "MyWorkflow", [], id: "wf-1", task_queue: "q") do
  {:ok, handle} -> handle
  {:error, %TemporalEx.Error.WorkflowAlreadyStarted{}} -> # handle duplicate
  {:error, %TemporalEx.Error.RPCError{message: msg}} -> # handle other errors
end
```

## Custom Data Converter

Implement the `TemporalEx.DataConverter` behaviour to use a custom serialization format:

```elixir
defmodule MyConverter do
  @behaviour TemporalEx.DataConverter

  @impl true
  def encoding, do: "my-encoding"

  @impl true
  def encode(term), do: {:ok, {serialize(term), %{}}}

  @impl true
  def decode(binary, _metadata), do: {:ok, deserialize(binary)}
end

{TemporalEx.Client, data_converter: MyConverter, ...}
```

## License

MIT - see [LICENSE](LICENSE).