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).
YAML-only: template actions
Section titled “YAML-only: template actions”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.
Example: Slack notification
Section titled “Example: Slack notification”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"Template variables
Section titled “Template variables”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 parameterstemplate.body_template— any value in the body objecttemplate.headers— header values (not header names)
Inline vs file-based schemas
Section titled “Inline vs file-based schemas”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
Section titled “Secrets”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.
Egress control
Section titled “Egress control”Every action must declare its egress profile:
profile: "none"— no outbound network access (pure computation)profile: "proxy_allowlist"— onlyallowed_domainsare reachable
egress: profile: "proxy_allowlist" allowed_domains: - "api.example.com" - "*.example.com" # wildcard subdomain matchingRisk levels and policy
Section titled “Risk levels and policy”The risk_level field determines how OPA policy treats the action:
| Level | Default policy behavior |
|---|---|
low | Allowed if principal has ACL access |
medium | Allowed if principal has ACL access |
high | Requires human approval |
critical | Requires human approval, ACL-scoped |
Customize approval requirements by editing the OPA policy. See Policy & Approvals.
Register and test
Section titled “Register and test”- Place the manifest in
definitions/manifests/. - Review the security summary before starting. In the TUI, open the Actions screen (
4), select the action, and presseto inspect its manifest and security summary.
CLI equivalent
latchgate actions slack_notify_channel- Reload manifests so the gate picks up the new action. In the TUI Actions screen (
4), pressRto hot-reload manifests and policy data without a full restart; alternatively restart the gate. - Verify:
curl http://localhost:3000/v1/actions | jq '.[] | select(.action_id == "slack_notify_channel")'- Grant the action to the appropriate principal. In the TUI, open the Setup screen (
6), Principals sub-tab (3), select the principal, and pressgto grant the action.
CLI equivalent
latchgate policy grant <principal> <action_id>- Test:
import asynciofrom 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())Rust/WASM: custom providers
Section titled “Rust/WASM: custom providers”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.
1. Create the provider crate
Section titled “1. Create the provider crate”cargo new --lib my_providercd my_providerAdd 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.
2. Implement the execute export
Section titled “2. Implement the execute export”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);3. Build
Section titled “3. Build”cargo build --target wasm32-wasip2 --release4. Compute the digest and create a manifest
Section titled “4. Compute the digest and create a manifest”sha256sum target/wasm32-wasip2/release/my_provider.wasmCreate 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.)5. Deploy
Section titled “5. Deploy”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.
Provider constraints
Section titled “Provider constraints”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.
Which path to choose
Section titled “Which path to choose”| Use case | Path | Available in |
|---|---|---|
| REST API call (any method, any service) | YAML template with builtin:http_api | v0.1 |
| Webhook notification | YAML template | v0.1 |
| SaaS integration (GitHub, Jira, Slack, Stripe, etc.) | YAML template | v0.1 |
| HTTP workflow with custom branching or post-processing | Rust/WASM with io/http | v0.1 |
| Filesystem workflow with custom logic | Rust/WASM with io/fs | v0.1 |
| SQL queries with custom validation | Rust/WASM with io/database | planned for future releases |
| Non-HTTP protocol (SMTP, AMQP, object storage) | Rust/WASM with appropriate io/* import | planned 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.