Call a capsule from your application
Use an LSF client SDK to call a capsule from Rust, TypeScript, Go, C, Java or C#. In this tutorial, run one example in your chosen language. It calls HTTP and storage capsules, handles an application error, cancels a request and checks whether a configuration change succeeded.
The example starts its own temporary node and stops it when finished. It does not change the node from your first tutorial. Client programs run outside capsules. To write the code that runs inside a capsule, use the capsule authoring guide.
1. Prepare the example
Use the Linux source checkout and Python environment from the first-node guide. Run the Bash blocks in one terminal, from the repository root. The current native clients connect to a node on the same machine; this example uses numeric loopback addresses. Browser applications use the separate application HTTP interface.
Install the tools for your chosen client language from the
toolchain setup. Rust, wit-bindgen and Zig are
also needed to build the example capsules that the client calls.
set -euo pipefail
umask 077
export CARGO_TARGET_DIR="$PWD/target"
mkdir -p "$CARGO_TARGET_DIR"
CLIENT_DEMO=$(mktemp -d "$CARGO_TARGET_DIR/client-demo.XXXXXXXX")
cargo build --locked -p latent -p latentd
The new directory keeps this attempt's files separate from earlier attempts. The first build can take several minutes.
2. Build the client in your language
Select one tab and run its commands. The SDKs come from this checkout.
- Rust
- TypeScript
- Go
- C
- Java
- C#/.NET
cargo build --locked -p latent-sdk --example provider_workflow
See the Rust client reference for its API.
npm ci --prefix sdk/typescript-client --ignore-scripts
npm --prefix sdk/typescript-client run build
The runnable example is a JavaScript ESM program using the compiled TypeScript client. See the TypeScript client reference.
(
export GOTOOLCHAIN=local GOWORK=off GOENV=off GOFLAGS=-mod=readonly
export GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org
export GOPRIVATE= GONOPROXY= GONOSUMDB=
python3 sdk/go/dependencies.py --check
python3 sdk/go/generate.py
go -C sdk/go build -trimpath -o "$CLIENT_DEMO/go-provider-workflow" ./cmd/provider-workflow
)
Generation also needs the pinned Buf tool. See the Go client setup.
python3 sdk/c/tools/validate.py --build-dir "$CLIENT_DEMO/c"
This builds the C library and runnable example. See the C client setup for its Zig, nanopb and nghttp2 prerequisites.
python3 sdk/java-client/tools/build.py build
Use the pinned Java 25 toolchain from the Java client setup.
dotnet restore sdk/dotnet/Latent.Sdk.ProviderWorkflow/Latent.Sdk.ProviderWorkflow.csproj \
--locked-mode --configfile sdk/dotnet/nuget.transport.config \
-p:ArtifactsPath="$CLIENT_DEMO/dotnet" \
-p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false \
-p:ImportDirectoryPackagesProps=false -p:UseSharedCompilation=false -nodeReuse:false
dotnet build sdk/dotnet/Latent.Sdk.ProviderWorkflow/Latent.Sdk.ProviderWorkflow.csproj \
--no-restore --disable-build-servers --artifacts-path "$CLIENT_DEMO/dotnet" \
-p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false \
-p:ImportDirectoryPackagesProps=false -p:UseSharedCompilation=false -nodeReuse:false
Use the pinned .NET 8 SDK and runtime from the C# client setup.
3. Prepare the capsules and temporary node inputs
python3 tools/build_guest_capsules.py --output "$CLIENT_DEMO/guests"
LSF_GUEST_CAPSULES="$CLIENT_DEMO/guests" \
LSF_PHASE3_WORKFLOW_FIXTURE_ROOT="$CLIENT_DEMO/inputs" \
cargo test --locked -p latentd --test phase3_workflow_fixture -- \
export_signed_provider_workflow_fixtures --exact --ignored --nocapture
Expect 1 passed; 0 failed. This prepares signed example packages and the
temporary node's trust settings. Run the next step soon afterward because the
demonstration signatures expire. These credentials are for this temporary
example; use package delivery when configuring
your own installed node.
4. Run your client
Define this helper once. It starts the temporary node, runs the selected client and saves the result:
run_client() {
local language=$1
shift
python3 tools/run_sdk_provider_workflow.py \
--cli "$CARGO_TARGET_DIR/debug/latent" --node "$CARGO_TARGET_DIR/debug/latentd" \
--fixture-root "$CLIENT_DEMO/inputs" --language "$language" \
-- "$@" > "$CLIENT_DEMO/result.json"
}
Select the same language you built in step 2:
- Rust
- TypeScript
- Go
- C
- Java
- C#/.NET
run_client rust "$CARGO_TARGET_DIR/debug/examples/provider_workflow"
run_client typescript "$(command -v node)" "$PWD/sdk/typescript-client/tests/provider-workflow.mjs"
run_client go "$CLIENT_DEMO/go-provider-workflow"
run_client c "$CLIENT_DEMO/c/provider-workflow"
run_client java "$(command -v java)" --sun-misc-unsafe-memory-access=allow \
--enable-native-access=ALL-UNNAMED -jar "$PWD/sdk/java-client/build/latent-java-client.jar"
run_client dotnet "$(command -v dotnet)" \
"$CLIENT_DEMO/dotnet/bin/Latent.Sdk.ProviderWorkflow/debug/Latent.Sdk.ProviderWorkflow.dll"
Show what happened:
python3 - "$CLIENT_DEMO/result.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as source:
result = json.load(source)
checks = result["participant"]["assertions"]
print("Client language:", result["language"])
print("HTTP capsule succeeded:", checks["httpGuest"])
print("Storage capsule succeeded:", checks["blobGuest"])
print("Cancellation completed:", checks["explicitCancellation"])
print("Configuration change recovered:", checks["mutationReceipt"])
print("Node stopped cleanly:", result["nodeShutdown"]["record"]["clean"])
PY
Expect your chosen language followed by five True results. The example also
tries intentional failures, such as a wrong credential and an application error.
Those cases are part of the demonstration. A command that exits with an error
has not completed the walkthrough.
Invoke an admitted guest
Now follow the code behind the example. Choose a language tab below; the source link opens the complete program with its imports and setup.
- Rust
- TypeScript
- Go
- C
- Java
- C#/.NET
for (provider, expected, assertion) in [("http", 2201, "httpGuest"), ("blob", 4, "blobGuest")] {
let response = client
.invoke(config.request(provider, provider, None)?, options())
.await
.map_err(|_| {
if provider == "http" {
"http-invocation-rpc"
} else {
"blob-invocation-rpc"
}
})?;
let actual = guest(&response.value)?;
if actual != expected {
return Err(match (provider, actual) {
("http", 10) => "http-guest-permission-denied",
("http", 11) => "http-guest-outcome-uncertain",
("http", _) => "http-guest-unexpected-result",
_ => "blob-guest-unexpected-result",
});
}
activations.push(format!("rust-{provider}"));
assertions.insert(assertion, true);
}
stage = "http-invocation";
check(guestU64(await client.invoke(request("http"))) === "2201", "http-guest-result");
activationIds.push("typescript-http"); assertions.httpGuest = true;
stage = "blob-invocation";
check(guestU64(await client.invoke(request("blob"))) === "4", "blob-guest-result");
activationIds.push("typescript-blob"); assertions.blobGuest = true;
for _, sample := range []struct {
name string
id string
value uint64
assertion string
}{{"http", "go-http", 2201, "httpGuest"}, {"blob", "go-blob", 4, "blobGuest"}} {
response, failure := owner.client.Invoke(ctx, owner.request(sample.name, sample.id), profile.CallOptions{})
if failure != nil {
return &stepFailure{reason: "participant-" + sample.assertion + "-rpc-failed", cause: failure}
}
actual, valid := u64Value(response.Value)
if !valid {
return errors.New("participant-" + sample.assertion + "-result-shape")
}
if actual != sample.value {
return fmt.Errorf("participant-%s-result-%d", sample.name, actual)
}
if !owner.pin(response.Value, sample.name) {
return errors.New("participant-" + sample.assertion + "-pin-failed")
}
owner.observe(response.Metadata)
if failure := owner.retain(ctx, sample.id); failure != nil {
return failure
}
owner.result.Assertions[sample.assertion] = true
}
const char *const suffixes[] = {"http", "blob", "declared", "platform"};
for (unsigned index = 0; index < 4; ++index) {
ex_result result = {0};
const char *function = index == 2 ? "fail" : index == 3 ? "spin" : NULL;
latent_profile_call *call = ex_invoke(&state->config, client, index > 1 ? 2 : index,
suffixes[index], function, false, 5000, &result);
if (!finish(state, client, call, &result) || !require(state, !result.failed, "guest-rpc")) return false;
bool valid = index < 2 ? ex_guest(&result, index == 0 ? 2201 : 4)
: index == 2 ? result.declared : result.platform;
if (!require(state, valid, guest_reason(&result, index))
|| !require(state, strcmp(result.identity, activations[index]) == 0, "invocation-identity")) return false;
state->admitted[index] = true;
state->assertions[index] = true;
}
for (String provider : new String[] {"http", "blob"}) {
stage = provider + "-guest";
var response = await(client.invoke(request(provider, provider, null, tenant, false), options())).value();
input.identity(response, provider, "java-" + provider);
require(ProviderInput.guest(response) == (provider.equals("http") ? 2201 : 4));
identities.add("java-" + provider); passed(provider + "Guest");
}
foreach (string provider in new[] { "http", "blob" })
{
Stage = provider + "-guest";
string identity = "dotnet-" + provider;
Profile.InvokeResponse response = (await client.InvokeAsync(input.Request(provider, identity), Calls, stop)).Value;
input.Receipt(response, provider, identity);
Require(Input.Guest(response) == (provider == "http" ? 2201UL : 4UL));
identities.Add(identity);
Passed(provider + "Guest");
}
The client sends the target capsule, function arguments, a time/resource budget and an activation ID. The HTTP capsule makes its permitted request through the node. The storage capsule writes and reads four bytes. Provider credentials stay with the node; the client does not need them.
A successful call, an error declared by the capsule, and a platform failure are different outcomes. Handle each explicitly. Keep the activation ID if the reply is lost so that you can ask about the original call.
Cancel the wait, then inspect the original identity
Stopping a client-side wait does not prove that the capsule has stopped. This example retains the activation ID, requests cancellation and checks its status:
- Rust
- TypeScript
- Go
- C
- Java
- C#/.NET
drop(pending);
let response = client
.cancel(
CancelRequest {
activation_id: identity.clone(),
reason: "explicit recovery".into(),
},
options(),
)
.await
.map_err(|_| "recovery-cancel-rpc")?;
require(
matches!(
response.value.disposition,
CancelDisposition::ACCEPTED | CancelDisposition::ALREADY_TERMINAL
),
"recovery-cancel-disposition",
)?;
controller.abort();
const result = await pending;
check(result.error instanceof RpcError && result.error.failure.category === Category.LocalCancelled
&& result.error.failure.identity.activationId === activationId && result.error.failure.outcome === Knowledge.Unknown, "local-cancellation-semantics");
const cancelled = await client.cancel({ activationId, reason: "explicit recovery" });
check([1, 2].includes(cancelled.value.disposition), "lost-response-explicit-cancel");
status, failure = owner.client.GetActivation(parent, profile.GetActivationRequest{ActivationId: identity}, profile.CallOptions{TimeoutMillis: reference(uint64(1000))})
if failure != nil {
return errors.New("participant-original-id-status-after-lost-response-failed")
}
owner.observe(status.Metadata)
if status.Value.TerminalState == nil {
if failure := owner.cancel(parent, identity); failure != nil {
return failure
}
}
if (kind == 0) {
state->api->cancel_local(call);
if (!require(state, pending.callbacks == 1 && pending.failed && pending.category == LATENT_PROFILE_FAILURE_CATEGORY_LOCAL_CANCELLED,
"local-cancellation-facts")) goto cleanup;
}
ex_result cancelled = {0};
latent_profile_cancel_request request = {ex_string(activations[kind + 5]), EX_TEXT("explicit C workflow cancellation")};
latent_profile_call_options options = ex_options(5000);
latent_profile_call *cancellation = state->api->cancel(latent_transport_profile(owner), &request, &options, ex_cancelled, &cancelled);
if (!finish(state, owner, cancellation, &cancelled) || !require(state, !cancelled.failed
&& (cancelled.disposition == LATENT_PROFILE_CANCEL_DISPOSITION_ACCEPTED
|| (kind == 0 && cancelled.disposition == LATENT_PROFILE_CANCEL_DISPOSITION_ALREADY_TERMINAL)), "explicit-cancel-disposition")) goto cleanup;
pending.cancel(true);
var failure = failed(pending);
require(pending.isCancelled() && failure.category().equals(Management.FailureCategory.LOCAL_CANCELLED)
&& failure.identity().activationId().equals(Optional.of(identity)));
var cancelled = await(observer.cancel(new Management.CancelRequest(identity, "explicit recovery"), options())).value();
require(cancelled.disposition().equals(Management.CancelDisposition.ACCEPTED) || cancelled.disposition().equals(Management.CancelDisposition.ALREADY_TERMINAL));
local.Cancel();
Profile.ClientFailure cancelled = await Failure(pending);
Require(cancelled.Category == Profile.FailureCategory.LocalCancelled && cancelled.Dispatched &&
cancelled.Outcome == Profile.OutcomeKnowledge.Unknown && cancelled.Identity.ActivationId == identity);
await Cancel(observer, identity);
Use a fresh, live context or cancellation token for that follow-up. Do not send the invocation again just to find out whether the first one finished. When your program finishes, close its client and await any required shutdown.
Inspect pages and reconcile a mutation
A management operation has its own operation ID. Keep that ID and the original request so that a lost reply can be resolved without accidentally creating a second change:
- Rust
- TypeScript
- Go
- C
- Java
- C#/.NET
let known = client
.get_policy_operation(
GetPolicyOperationRequest {
operation_id: operation_id.clone(),
},
options(),
)
.await
.map_err(|_| "policy-operation-rpc")?;
require(
known.value.receipt.as_ref() == Some(&receipt),
"operation-recovery-receipt",
)?;
stage = "operation-recovery";
const receipt = await client.getPolicyOperation({ operationId });
check(isDeepStrictEqual(receipt.value.receipt, created.value.receipt), "operation-recovery");
lookup, failure := owner.client.GetPolicyOperation(ctx, profile.GetPolicyOperationRequest{OperationId: request.OperationId}, profile.CallOptions{})
if failure != nil || !sameReceipt(created.Value.Receipt, lookup.Value.Receipt) || lookup.Metadata.Outcome != profile.OutcomeKnowledgeObserved {
return errors.New("participant-original-policy-operation-recovery-failed")
}
owner.observe(lookup.Metadata)
latent_profile_get_policy_operation_request lookup = {EX_TEXT("c-policy-create")};
result = (ex_result){0};
call = state->api->get_policy_operation(client, &lookup, &options, ex_operation, &result);
if (!finish(state, owner, call, &result) || !require(state, !result.failed && result.has_receipt && result.audit_absent
&& ex_receipt_equal(&created.receipt, &result.receipt), "original-operation-recovery")) return false;
var recovered = await(client.getPolicyOperation(new Management.GetPolicyOperationRequest("java-policy-create"), options()));
absentAudit(recovered.metadata());
require(recovered.value().receipt().equals(Optional.of(receipt)));
Profile.ClientResponse<Profile.GetPolicyOperationResponse> recovered = await client.GetPolicyOperationAsync(new(request.OperationId), Calls, stop);
AbsentAudit(recovered.Metadata);
Require(recovered.Value.Receipt == receipt);
The example reads one result page at a time and follows its continuation token. It also demonstrates a conflicting change. Check the current state before deciding to submit a new operation.
Finish or troubleshoot
The runner stops its temporary node and local HTTP server automatically. Your
builds and result remain under the directory printed by printf '%s\n' "$CLIENT_DEMO".
You can remove that directory when you no longer need it.
| What you see | What to do |
|---|---|
| Missing compiler, generator or dependency | Revisit the setup link for your selected language and the common capsule toolchain |
| Expired demonstration signature | Start again at step 1 with a fresh demo directory |
| Connection or authentication failure | Use the runner's temporary configuration and keep its credential file private |
| An example command exits with an error | Keep its diagnostic and result files; start the next attempt in a fresh demo directory |
Continue with provider failure and recovery or policy reconciliation. For detailed limits and language-specific ownership rules, see the shared client API reference.