Skip to content

Custom Actions

LatchGate ships with a set of built-in actions, but you can define your own. There are two paths: YAML-only (recommended for most use cases) and Rust/WASM (for advanced providers).

Most custom actions are HTTP-based. Instead of writing provider code, you write a YAML manifest that configures the built-in http_api provider with your URL, method, headers, and body templates.

Create definitions/manifests/slack_notify_channel.yaml:

action_id: "slack_notify_channel"
version: "1.0.0"
provider_module_digest: "builtin:http_api"
template:
method: POST
url_template: "https://slack.com/api/chat.postMessage"
headers:
Content-Type: "application/json"
body_template:
channel: "{{channel}}"
text: "{{message}}"
io:
request_schema:
type: object
properties:
channel:
type: string
minLength: 1
description: "Slack channel ID (e.g. C01234567)"
message:
type: string
minLength: 1
maxLength: 4000
description: "Message text"
required: [channel, message]
additionalProperties: false
max_request_bytes: 8192
max_response_bytes: 65536
secrets:
- name: "SLACK_BOT_TOKEN"
required: true
egress:
profile: "proxy_allowlist"
allowed_domains: ["slack.com"]
risk_level: "medium"
resource_limits:
fuel: 5000000
memory_mb: 128
timeout_seconds: 15
max_io_calls: 3
verifier_kind: http_status
declared_side_effects:
- "http_write"

The {{variable}} placeholders in url_template, body_template, and header values are resolved from the validated request body at execution time. The kernel resolves templates after schema validation, so every variable is guaranteed to match the declared JSON Schema.

Variables can appear in:

  • template.url_template — path segments, query parameters
  • template.body_template — any value in the body object
  • template.headers — header values (not header names)

Manifests support both inline JSON Schemas and file references:

# Inline (self-contained, recommended for custom actions):
io:
request_schema:
type: object
properties:
query: { type: string }
required: [query]
# File reference (reusable across manifests):
io:
request_schema: "../schemas/my_action_request.json"

Secrets declared in the manifest are decrypted from the SOPS-encrypted secrets file at execution time and injected by the host I/O layer at the transport level. They never enter the WASM sandbox. The required flag controls execution behavior:

secrets:
- name: "API_KEY"
required: true # execution fails if this key is missing from the SOPS file
- name: "BACKUP_KEY"
required: false # execution proceeds without this secret (default)

The name must match a top-level key in the SOPS-encrypted file exactly (case-sensitive). When required: true and the key is missing, the action fails before the WASM provider loads. When required is omitted, it defaults to false.

Configure the secrets file in latchgate.toml:

sops_secrets_file = "/etc/latchgate/secrets.enc.yaml"
sops_key_file = "/etc/latchgate/sops-age.key"

See Secrets Management for the full SOPS setup guide, encryption backends, and rotation procedures.

Every action must declare its egress profile:

  • profile: "none" — no outbound network access (pure computation)
  • profile: "proxy_allowlist" — only allowed_domains are reachable
egress:
profile: "proxy_allowlist"
allowed_domains:
- "api.example.com"
- "*.example.com" # wildcard subdomain matching

The risk_level field determines how OPA policy treats the action:

LevelDefault policy behavior
lowAllowed if principal has ACL access
mediumAllowed if principal has ACL access
highRequires human approval
criticalRequires human approval, ACL-scoped

Customize approval requirements by editing the OPA policy. See Policy & Approvals.

  1. Place the manifest in definitions/manifests/.
  2. Review the security summary before starting. In the TUI, open the Actions screen (4), select the action, and press e to inspect its manifest and security summary.
CLI equivalent
Terminal window
latchgate actions slack_notify_channel
  1. Reload manifests so the gate picks up the new action. In the TUI Actions screen (4), press R to hot-reload manifests and policy data without a full restart; alternatively restart the gate.
  2. Verify:
Terminal window
curl http://localhost:3000/v1/actions | jq '.[] | select(.action_id == "slack_notify_channel")'
  1. Grant the action to the appropriate principal. In the TUI, open the Setup screen (6), Principals sub-tab (3), select the principal, and press g to grant the action.
CLI equivalent
Terminal window
latchgate policy grant <principal> <action_id>
  1. Test:
import asyncio
from latchgate import LatchGateClient
async def main():
async with LatchGateClient(agent_id="test") as client:
result = await client.execute("slack_notify_channel", {
"channel": "C01234567",
"message": "Hello from LatchGate!",
})
print(result.receipt_id)
asyncio.run(main())

In v0.1, custom WASM providers are supported for HTTP-based and filesystem-based actions — the runtime links latchgate:io/http, latchgate:io/fs, and latchgate:io/log. Use this path when you need logic the YAML template cannot express: branching workflows, response post-processing, or HTTP patterns the template engine does not cover. Custom providers for non-HTTP protocols (SQL, SMTP, AMQP, object storage) require host I/O backends planned for future releases and are not loaded by the v0.1 runtime.

Terminal window
cargo new --lib my_provider
cd my_provider

Add to Cargo.toml:

[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

wit-bindgen 0.36 matches the version used by LatchGate’s built-in providers (see providers/Cargo.toml). Pinning to the same minor version avoids ABI mismatches.

Adjust the path: argument so it resolves to the LatchGate wit/ directory from your crate. If you place the provider crate inside the LatchGate workspace at providers/<name>/, use "../../wit" (matching the built-in providers). For an out-of-tree project, vendor the WIT files into your repo and point at that copy.

wit_bindgen::generate!({
path: "../../wit",
world: "provider",
});
use exports::latchgate::provider::execute::Guest;
struct MyProvider;
impl Guest for MyProvider {
fn execute(task_json: String) -> Result<String, String> {
let input: serde_json::Value = serde_json::from_str(&task_json)
.map_err(|e| format!("invalid input: {e}"))?;
// Use host I/O imports to interact with external systems.
// Only imports declared in the manifest are available.
let response = latchgate::io::http::fetch(
input["url"].as_str().ok_or("missing url")?,
).map_err(|e| format!("http error: {e}"))?;
Ok(serde_json::json!({
"status": response.status,
"body": response.body,
}).to_string())
}
}
export!(MyProvider);
Terminal window
cargo build --target wasm32-wasip2 --release

4. Compute the digest and create a manifest

Section titled “4. Compute the digest and create a manifest”
Terminal window
sha256sum target/wasm32-wasip2/release/my_provider.wasm

Create definitions/manifests/my_action.yaml:

action_id: "my_action"
version: "1.0.0"
provider_module_digest: "sha256:<the-digest-from-above>"
provider_source: "my_provider.wasm"
required_imports:
- "latchgate:io/http"
- "latchgate:io/log"
# ... rest of manifest (schemas, egress, risk_level, etc.)

Copy the .wasm to target/providers/ and the manifest to definitions/manifests/. Or use make providers which builds all providers, computes digests, and updates manifests automatically.

Providers execute in a fresh WASM sandbox per call with:

  • No network access (all I/O through host imports)
  • No filesystem access
  • No syscalls; secrets never enter the sandbox
  • Bounded CPU (fuel metering), memory, I/O calls, and wall-clock time
  • Only declared imports linked (undeclared = instantiation failure)

See WASM Providers for the full sandbox model and available I/O interfaces.

Use casePathAvailable in
REST API call (any method, any service)YAML template with builtin:http_apiv0.1
Webhook notificationYAML templatev0.1
SaaS integration (GitHub, Jira, Slack, Stripe, etc.)YAML templatev0.1
HTTP workflow with custom branching or post-processingRust/WASM with io/httpv0.1
Filesystem workflow with custom logicRust/WASM with io/fsv0.1
SQL queries with custom validationRust/WASM with io/databaseplanned for future releases
Non-HTTP protocol (SMTP, AMQP, object storage)Rust/WASM with appropriate io/* importplanned for future releases

YAML templates cover ~90% of real-world actions. Start with a template; graduate to Rust/WASM only when you need custom logic.