Skip to main content

Standalone Activities Feature Guide

View Markdown

Standalone Activities are Activities that run independently, without being orchestrated by a Workflow. Instead of executing an Activity from within a Workflow Definition using ctx.execute_activity(), you execute a Standalone Activity directly from a Client.

The way you write the Activity and register it with a Worker is identical to Workflow Activities. The only difference is that you execute a Standalone Activity directly from your Temporal Client.

tip

New to Standalone Activities? Start with the Standalone Activities Quickstart.

This page covers the following:

info

This documentation uses source code from the standalone_activities sample.

Prerequisites

Standalone Activities require:

  • Rust 1.92.0+
  • Temporal Rust SDK v1.0.0 or higher
  • Temporal CLI v1.9.1 or higher

The Standalone Activities Quickstart walks through installing these.

Start a Standalone Activity without waiting for the result

Starting a Standalone Activity means sending a request to the Temporal Server to durably enqueue your Activity job, without waiting for it to be executed by your Worker.

Use Client::start_activity to start a Standalone Activity and get a handle without waiting for the result:

start_activity.rs

let options = ActivityStartOptions::with_start_to_close_timeout(
"standalone-activities",
"standalone-activity-id",
Duration::from_secs(10),
)
.build();

// Returns as soon as the server has durably enqueued the activity.
let handle = client
.start_activity(
GreetingActivities::compose_greeting,
("Hello".to_string(), "Temporal".to_string()),
options,
)
.await?;

println!(
"Started activity, id: {} run_id: {:?}",
handle.activity_id(),
handle.run_id()
);

The first argument identifies the Activity to run. Passing the Activity method itself, such as GreetingActivities::compose_greeting, gives you a typed ActivityHandle, so the input and output types are checked at compile time. The second argument is the Activity's input; if your Activity takes several values, pass them as a tuple.

ActivityStartOptions requires a Task Queue, an Activity ID, and a close timeout. The with_start_to_close_timeout and with_schedule_to_close_timeout constructors return a builder with that timeout already set; call .build() to finish, or set additional fields such as retry_policy, heartbeat_timeout, id_reuse_policy, or priority first.

With the Temporal Server and Worker running, open a new terminal in the crates/sdk directory and run:

cargo run --features examples --example standalone-activities-start

Or use the Temporal CLI:

temporal activity start \
--type 'GreetingActivities::compose_greeting' \
--activity-id standalone-activity-id \
--task-queue standalone-activities \
--start-to-close-timeout 10s \
--input '["Hello","Temporal"]'

By default, the #[activities] macro names each Activity <ImplType>::<method_name>, so the compose_greeting method on GreetingActivities registers as GreetingActivities::compose_greeting. Use that name when referring to the Activity from the CLI or from a List Filter query.

Get a handle to an existing Standalone Activity

Use Client::get_activity_handle to create an ActivityHandle for a previously started Standalone Activity:

get_activity_handle.rs

// Passing `None` for the run ID targets the latest run with this activity ID.
let handle = client.get_activity_handle(
GreetingActivities::compose_greeting,
"standalone-activity-id",
None,
);

Pass None for the run ID to target the latest run of the given Activity ID, or pass Some(run_id) to target a specific run.

If you don't have the Activity definition on hand, for example in a tool that operates on Activities it didn't start, use Client::get_untyped_activity_handle instead. You can still describe, cancel, and terminate through an untyped handle.

You can then use the handle to wait for the result, describe, cancel, or terminate the Activity:

handle.result().await?; // block until the activity completes
handle.describe(Default::default()).await?; // status, timestamps, attempt, ...
handle.cancel(Default::default()).await?; // request cancellation
handle.terminate(Default::default()).await?; // force-close the activity

describe takes an ActivityDescribeOptions. Its include_input, include_outcome, include_heartbeat_details, and include_last_failure fields are off by default, because those fields carry Payloads that can be arbitrarily large. Turn them on only when you need them:

let description = handle
.describe(
ActivityDescribeOptions::builder()
.include_outcome(true)
.build(),
)
.await?;

println!("Status: {:?}", description.status());
println!("Type: {}", description.activity_type());
println!("Attempt: {}", description.attempt());

The accessors on the description (status(), activity_type(), schedule_time(), and so on) come from the ActivityExecutionInfoLike trait, so bring it into scope to use them.

Run it, after executing an Activity with one of the samples above:

cargo run --features examples --example standalone-activities-get-handle

Or use the Temporal CLI to describe an Activity by ID:

temporal activity describe --activity-id standalone-activity-id

Wait for the result of a Standalone Activity

The Rust SDK has no single call that both starts an Activity and waits for its result. Call start_activity to durably enqueue the Activity, then ActivityHandle::result to block until it completes and return the result:

execute_activity.rs

// There is no single "execute" call: start the activity, then await its result.
let handle = client
.start_activity(
GreetingActivities::compose_greeting,
("Hello".to_string(), "Temporal".to_string()),
options,
)
.await?;

let result = handle.result().await?;
println!("Activity result: {result}");

Because the handle is typed, result returns the Activity's own output type, String in this case, with no downcasting. It fails with an ActivityResultError if the Activity failed, was cancelled, or was terminated.

Splitting start from result also means you don't have to wait in the same process, or even the same program, that started the Activity: get a handle later and call result on it.

Or use the Temporal CLI to wait for a result by Activity ID:

temporal activity result --activity-id standalone-activity-id

List Standalone Activities

Use Client::list_activities to list Standalone Activity Executions that match a List Filter query. The result is a ListActivitiesStream, a Stream of ActivityExecutionInfo values that fetches pages from the server on demand as the stream is consumed.

These APIs return only Standalone Activity Executions. Activities running inside Workflows are not included.

list_activities.rs

use futures::StreamExt;
use temporalio_client::ActivityExecutionInfoLike;

let mut executions =
client.list_activities("TaskQueue = 'standalone-activities'", Default::default());

while let Some(execution) = executions.next().await {
let execution = execution?;
println!(
"{} {} {:?}",
execution.activity_id(),
execution.activity_type(),
execution.status()
);
}

list_activities is not async. It returns the stream immediately, and the requests happen as you poll it. Each item is a Result, because a page fetch can fail partway through the stream.

Run it:

cargo run --features examples --example standalone-activities-list

Or use the Temporal CLI:

temporal activity list

The query parameter accepts the same List Filter syntax used for Workflow Visibility. For example, ActivityType = 'GreetingActivities::compose_greeting' AND ExecutionStatus = 'Running'.

Count Standalone Activities

Use Client::count_activities to count Standalone Activity Executions that match a List Filter query. This returns the total count of executions (running, completed, failed, etc.) — not the number of queued tasks. It works the same way as counting Workflow Executions.

count_activities.rs

let count = client
.count_activities("TaskQueue = 'standalone-activities'", Default::default())
.await?;

println!("Total: {}", count.count());
// Non-empty only when the query has a GROUP BY clause.
for group in count.groups() {
println!(" {:?} => {}", group.get::<String>(0), group.count());
}

If the query has a GROUP BY clause, groups() holds the per-group counts and count() is their sum; otherwise groups() is empty. Group values are typed: read them with get::<T>(index), or with try_get if you want to handle a deserialization failure rather than get None.

Run it:

cargo run --features examples --example standalone-activities-count

Or use the Temporal CLI:

temporal activity count

Run Standalone Activities with Temporal Cloud

The Worker and Client code in the Standalone Activities Quickstart use ClientOptions::load_from_config, so the same code works against Temporal Cloud — configure the connection via environment variables or a TOML profile. No code changes are needed.

For a step-by-step guide on connecting to Temporal Cloud, including Namespace creation, certificate generation, and authentication setup in the Cloud UI, see Connect to Temporal Cloud.

Connect with mTLS

Set these environment variables with values from your Temporal Cloud Namespace settings:

export TEMPORAL_ADDRESS=<your-namespace>.<your-account-id>.tmprl.cloud:7233
export TEMPORAL_NAMESPACE=<your-namespace>.<your-account-id>
export TEMPORAL_TLS_CLIENT_CERT_PATH='path/to/your/client.pem'
export TEMPORAL_TLS_CLIENT_KEY_PATH='path/to/your/client.key'

Connect with an API key

Set these environment variables with values from your Temporal Cloud API key settings:

export TEMPORAL_ADDRESS=<your-namespace>.<your-account-id>.tmprl.cloud:7233
export TEMPORAL_NAMESPACE=<your-namespace>.<your-account-id>
export TEMPORAL_API_KEY=<your-api-key>

Then run the Worker and starter code as shown in the Standalone Activities Quickstart.