Template Schema
Complete field reference for Earl template files — every block, field, and type.
This page is a field-by-field reference. For worked examples, see the protocol pages: HTTP, GraphQL, gRPC, Bash, SQL, Browser.
File structure
A template file is an HCL file with a fixed top-level shape:
version = 1
provider = "github"
categories = ["scm", "issues"]
environments { ... } # optional
command "name" { ... } # one or moreTop-level fields
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | yes | Schema version. Must be 1. |
provider | string | yes | Provider identifier. Used in earl call <provider>.<command>. |
categories | list of strings | no | Labels applied to every command in the file. Used for discovery and filtering. |
environments | block | no | Provider-level environment definitions. See Environments. |
command "<name>" | block | yes | Command definition. Repeatable. The name becomes the second part of earl call <provider>.<name>. |
command block
command "search_repos" {
title = "Search repositories"
summary = "Search GitHub repos by query"
description = "..."
categories = ["search"]
annotations { ... }
param "query" { ... }
operation { ... }
result { ... }
environment_overrides { ... }
}| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Short display name shown in earl list. |
summary | string | yes | One-line description exposed to the agent in the MCP tool listing. |
description | string | yes | Full description the agent reads to decide whether to call the command. Supports Markdown. |
categories | list of strings | no | Labels for this specific command. Merged with provider-level categories for filtering. |
annotations | block | no | Mode, secrets list, and environment switching flags. |
param "<name>" | block | no | Parameter declaration. Repeatable. |
operation | block | yes | The request to execute. Shape varies by protocol. |
result | block | no | How to decode and format the response. Defaults to decode = "auto" and output = "{{ result }}". |
environment_overrides | block | no | Per-environment operation replacements. |
annotations block
annotations {
mode = "read"
secrets = ["github.token"]
allow_environment_protocol_switching = false
}| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "write" | "read" or "write". Write mode prompts for confirmation unless the caller passes --yes. Use "read" for commands that only retrieve data. |
secrets | list of strings | [] | Keys the command needs. Earl checks that these exist before executing. Every key referenced in an auth block must appear here. |
allow_environment_protocol_switching | boolean | false | When true, the active environment can switch the operation protocol (e.g. from http to grpc). Disabled by default as a safety measure. |
Note: the mode field defaults to "write", not "read". Omitting the annotations block means the command requires confirmation. Always set mode = "read" explicitly for read-only commands.
param block
param "per_page" {
type = "integer"
required = false
default = 30
description = "Results per page (max 100)"
}| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Parameter type. See types table below. |
required | boolean | no | Whether the caller must supply this parameter. Defaults to false. |
default | any | no | Default value used when the parameter is not supplied. Only valid when required = false. |
description | string | no | Shown to the agent so it knows what to pass. |
Parameter types
| Type | Description |
|---|---|
"string" | UTF-8 text. |
"integer" | Whole number. |
"number" | Floating-point number. |
"boolean" | true or false. |
"array" | JSON array. |
"object" | JSON object. |
"null" | Null value. Rarely needed. |
Inside the operation block, parameters are available as args.<name>:
url = "https://api.github.com/repos/{{ args.owner }}/{{ args.repo }}"operation block
The operation block shape depends on the protocol field. HTTP fields are flat at the operation level. Every other protocol wraps its fields in a nested block.
HTTP
operation {
protocol = "http"
method = "GET" # GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
url = "https://api.example.com/items/{{ args.id }}"
path = "/v2/items" # optional; appended to url
stream = false
headers = { Accept = "application/json" }
query = { filter = "{{ args.filter }}" }
cookies = { session = "{{ args.session }}" }
auth { ... }
body { ... }
transport { ... }
}| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "http". |
method | string | yes | HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. |
url | string | yes | Full URL. Supports Jinja expressions. |
path | string | no | Path segment appended to url. Useful when the base URL comes from an environment variable. |
query | map | no | Query string parameters. Values support Jinja expressions. |
headers | map | no | Request headers. Values support Jinja expressions. |
cookies | map | no | Cookies sent with the request. Values support Jinja expressions. |
auth | block | no | Authentication. See auth block. |
body | block | no | Request body. See body block. |
stream | boolean | no | Set true to enable streaming. Defaults to false. See Streaming. |
transport | block | no | Timeout, retries, redirects, TLS, proxy. See transport block. |
GraphQL
operation {
protocol = "graphql"
url = "https://api.github.com/graphql"
auth {
kind = "bearer"
secret = "github.token"
}
graphql {
query = <<-GQL
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
stargazerCount
}
}
GQL
operation_name = "MyQuery" # optional
variables = {
owner = "{{ args.owner }}"
repo = "{{ args.repo }}"
}
}
transport { ... }
}GraphQL operation-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "graphql". |
url | string | yes | GraphQL endpoint URL. |
method | string | no | Defaults to POST. Override only if the server requires GET. |
query | map | no | URL query parameters (distinct from the GraphQL query). |
headers | map | no | Request headers. Accept and Content-Type default to application/json. |
cookies | map | no | Cookies sent with the request. |
auth | block | no | Authentication. |
graphql | block | yes | The GraphQL payload. |
stream | boolean | no | Enable streaming. Defaults to false. |
transport | block | no | Transport settings. |
graphql inner block fields:
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | The GraphQL query or mutation. Heredoc syntax recommended for multi-line queries. |
operation_name | string | no | operationName sent in the request body. Required when the document contains multiple operations. |
variables | map | no | Variables map. Values support Jinja expressions. |
gRPC
operation {
protocol = "grpc"
url = "https://grpc.example.com"
auth {
kind = "bearer"
secret = "provider.token"
}
grpc {
service = "example.v1.ExampleService"
method = "GetItem"
descriptor_set_file = "example.pb" # optional
body = {
id = "{{ args.id }}"
}
}
transport { ... }
}gRPC operation-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "grpc". |
url | string | yes | gRPC server URL. |
headers | map | no | gRPC metadata headers. |
auth | block | no | Authentication. |
grpc | block | yes | gRPC call configuration. |
stream | boolean | no | Enable server-streaming. Defaults to false. |
transport | block | no | Transport settings. |
grpc inner block fields:
| Field | Type | Required | Description |
|---|---|---|---|
service | string | yes | Fully qualified service name, e.g. example.v1.ExampleService. |
method | string | yes | RPC method name, e.g. GetItem. |
body | map | no | Request message fields. Values support Jinja expressions. |
descriptor_set_file | string | no | Path to a compiled .pb descriptor set file. Omit to use server reflection (gRPC reflection v1). |
Earl uses gRPC reflection v1. If the server only exposes v1alpha or no reflection at all, provide a compiled descriptor set.
Bash
operation {
protocol = "bash"
bash {
script = <<-SH
jq -r '.[] | .name' {{ args.input_file }}
SH
env = {
MY_VAR = "{{ args.value }}"
}
cwd = "/tmp"
sandbox {
network = false
max_time_ms = 30000
max_output_bytes = 1048576
}
}
stream = false
}Bash operation-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "bash". |
bash | block | yes | Script configuration. |
stream | boolean | no | Stream stdout as lines. Defaults to false. |
bash inner block fields:
| Field | Type | Required | Description |
|---|---|---|---|
script | string | yes | Shell script to execute. Supports Jinja expressions. |
env | map | no | Environment variables set before the script runs. Values support Jinja expressions. |
cwd | string | no | Working directory for the script. |
sandbox | block | no | Resource and network limits. |
bash.sandbox fields:
| Field | Type | Default | Description |
|---|---|---|---|
network | boolean | false | Whether the script can make network requests. |
writable_paths | list of strings | [] | Filesystem paths the script may write to. |
max_time_ms | integer | — | Wall-clock timeout in milliseconds. |
max_output_bytes | integer | — | Maximum combined stdout+stderr size in bytes. |
max_memory_bytes | integer | — | Memory limit in bytes. |
max_cpu_time_ms | integer | — | CPU time limit in milliseconds. |
Note: Earl also blocks all private and loopback IP ranges at the SSRF layer regardless of sandbox settings. network = true only enables outbound requests to public addresses.
SQL
operation {
protocol = "sql"
sql {
connection_secret = "myapp.db_url"
query = "SELECT id, name FROM users WHERE status = $1 LIMIT $2"
params = ["{{ args.status }}", "{{ args.limit }}"]
sandbox {
read_only = true
max_rows = 100
max_time_ms = 5000
}
}
}SQL operation-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "sql". |
sql | block | yes | Query configuration. |
transport | block | no | Transport settings (connection-level). |
sql inner block fields:
| Field | Type | Required | Description |
|---|---|---|---|
connection_secret | string | yes | Key name in the OS keychain whose value is the database connection URL. |
query | string | yes | SQL query with positional placeholders. Syntax varies by database: $1, $2... for PostgreSQL; ? for MySQL and SQLite. |
params | list | no | Values for the positional placeholders. Each element supports Jinja expressions and must be quoted as an HCL string: ["{{ args.limit }}"]. Earl coerces the rendered value to the correct SQL type. |
sandbox | block | no | Query limits. |
sql.sandbox fields:
| Field | Type | Description |
|---|---|---|
read_only | boolean | Restrict the connection to read-only transactions. |
max_rows | integer | Limit the number of rows returned. |
max_time_ms | integer | Query execution timeout in milliseconds. |
Browser
operation {
protocol = "browser"
browser {
session_id = "{{ args.session_id }}"
headless = true
timeout_ms = 30000
on_failure_screenshot = true
steps = [
{ action = "navigate", url = "{{ args.url }}" },
{ action = "snapshot" },
]
}
}Browser operation-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | yes | Must be "browser". |
browser | block | yes | Browser configuration. |
browser inner block fields:
| Field | Type | Default | Description |
|---|---|---|---|
steps | list of objects | yes | Ordered list of step objects to execute. Each object must include an action field. |
session_id | string | — | Stable identifier for a persistent browser session. Omit for a one-shot command. |
headless | boolean | true | Run Chrome in headless mode. |
timeout_ms | integer | 30000 | Global timeout in milliseconds for the entire command. |
on_failure_screenshot | boolean | true | Capture a screenshot and attach it to the error output when any non-optional step fails. |
Every step object has two cross-cutting optional fields in addition to its action-specific fields:
| Field | Default | Description |
|---|---|---|
action | — | Required. Identifies the step type. See step reference below. |
optional | false | When true, failure on this step is ignored and execution continues. |
timeout_ms | — | Per-step timeout in milliseconds, overrides the command-level timeout. |
Step reference
Navigation
| Action | Key fields | Result shape |
|---|---|---|
navigate | url (required), expected_status, timeout_ms | {"ok": true} |
navigate_back | — | {"ok": true} |
navigate_forward | — | {"ok": true} |
reload | — | {"ok": true} |
Observation
| Action | Key fields | Result shape |
|---|---|---|
snapshot | — | {"text": "<accessibility tree>", "raw": [...]} |
screenshot | path, type (png/jpeg), full_page, ref | {"data": "<base64>", "path": "..."} |
pdf_save | path | {"path": "..."} |
Interaction
| Action | Key fields | Result shape |
|---|---|---|
click | ref or selector, double_click | {"ok": true} |
hover | ref or selector | {"ok": true} |
fill | ref or selector, text, submit | {"ok": true} |
fill_form | fields (array of {ref/selector, value, type}) | {"ok": true} |
select_option | ref or selector, values (array) | {"ok": true} |
check | ref or selector | {"ok": true} |
uncheck | ref or selector | {"ok": true} |
press_key | key (e.g. "Enter", "Tab", "Escape") | {"ok": true} |
drag | start_ref/start_selector, end_ref/end_selector | {"ok": true} |
file_upload | ref or selector, paths (array) | {"ok": true} |
handle_dialog | accept (boolean), prompt_text | {"ok": true, "accept": ...} |
Mouse
| Action | Key fields | Result shape |
|---|---|---|
mouse_move | x, y | {"ok": true} |
mouse_click | x, y, button | {"ok": true} |
mouse_drag | start_x, start_y, end_x, end_y | {"ok": true} |
mouse_down | x, y, button | {"ok": true} |
mouse_up | x, y, button | {"ok": true} |
mouse_wheel | delta_x, delta_y | {"ok": true} |
Wait and assertions
| Action | Key fields | Result shape |
|---|---|---|
wait_for | text, text_gone, time (seconds), timeout_ms (optional — defaults to command timeout_ms) | {"ok": true} |
verify_text_visible | text | {"ok": true} |
verify_element_visible | role, accessible_name | {"ok": true} |
verify_list_visible | items (array) | {"ok": true} |
verify_value | value | {"ok": true} |
JavaScript
| Action | Key fields | Result shape |
|---|---|---|
evaluate | function (JS arrow function string), ref | {"value": ...} |
run_code | code (JS statements string) | {"ok": true} |
Cookies
| Action | Key fields | Result shape |
|---|---|---|
cookie_list | domain (filter, optional) | {"cookies": [...]} |
cookie_get | name | {"value": "..."} |
cookie_set | name, value, domain, path, expires, http_only, secure | {"ok": true} |
cookie_delete | name | {"ok": true} |
cookie_clear | — | {"ok": true} |
Storage
| Action | Key fields | Result shape |
|---|---|---|
local_storage_get | key | {"value": "..."} |
local_storage_set | key, value | {"ok": true} |
local_storage_delete | key | {"ok": true} |
local_storage_clear | — | {"ok": true} |
session_storage_get | key | {"value": "..."} |
session_storage_set | key, value | {"ok": true} |
session_storage_delete | key | {"ok": true} |
session_storage_clear | — | {"ok": true} |
storage_state | path (optional) | {"cookies": [...], "local_storage": {...}} |
set_storage_state | path | {"ok": true} |
Tabs and viewport
| Action | Key fields | Result shape |
|---|---|---|
tabs | operation (list/new/close/select), index | {"tabs": [...]} or {"ok": true} |
resize | width, height | {"ok": true} |
close | — | {"ok": true} |
Network (session mode only)
| Action | Key fields | Result shape |
|---|---|---|
route | pattern, status, body, content_type | {"ok": true} |
route_list | — | {"routes": [...]} |
unroute | pattern | {"ok": true} |
console_messages | — | {"messages": [...]} |
console_clear | — | {"ok": true} |
network_requests | — | {"requests": [...]} |
network_clear | — | {"ok": true} |
download | path | {"path": "..."} |
Recording
| Action | Key fields | Result shape |
|---|---|---|
start_video | width, height | {"ok": true} |
stop_video | — | {"path": "..."} |
start_tracing | — | {"ok": true} |
stop_tracing | — | {"path": "..."} |
Utility
| Action | Key fields | Result shape |
|---|---|---|
generate_locator | ref | {"selector": "..."} |
body block (HTTP only)
The body block inside an HTTP operation has a kind discriminator field.
kind = "json"
body {
kind = "json"
value = {
name = "{{ args.name }}"
count = "{{ args.count }}"
}
}Sends Content-Type: application/json. The value map supports nested objects and Jinja expressions.
kind = "form_urlencoded"
body {
kind = "form_urlencoded"
fields = {
username = "{{ args.username }}"
password = "{{ args.password }}"
}
}Sends Content-Type: application/x-www-form-urlencoded.
kind = "multipart"
body {
kind = "multipart"
parts = [
{
name = "file"
file_path = "{{ args.path }}"
content_type = "application/octet-stream"
filename = "upload.bin"
},
{
name = "description"
value = "{{ args.description }}"
}
]
}Sends Content-Type: multipart/form-data. Each part in the parts list has:
| Field | Required | Description |
|---|---|---|
name | yes | Form field name. |
value | one of three | Text content for the part. Supports Jinja expressions. |
bytes_base64 | one of three | Base64-encoded binary content. |
file_path | one of three | Path to a file whose contents become the part body. Supports Jinja expressions. |
content_type | no | MIME type for the part. |
filename | no | Filename reported in the Content-Disposition header. |
Exactly one of value, bytes_base64, or file_path is required per part.
kind = "raw_text"
body {
kind = "raw_text"
value = "{{ args.payload }}"
content_type = "text/xml"
}Sends the rendered string as raw bytes with the given content_type (defaults to text/plain).
kind = "raw_bytes_base64"
body {
kind = "raw_bytes_base64"
value = "{{ args.base64_data }}"
content_type = "application/octet-stream"
}Decodes the rendered string as base64 and sends the resulting bytes.
kind = "file_stream"
body {
kind = "file_stream"
path = "{{ args.file_path }}"
content_type = "application/pdf"
}Reads a file from disk and sends its contents as the request body.
auth block
The auth block lives inside an operation block and has a kind discriminator field. Every secret key referenced in auth must also appear in annotations.secrets.
kind = "bearer"
auth {
kind = "bearer"
secret = "provider.token"
}| Field | Required | Description |
|---|---|---|
kind | yes | "bearer" |
secret | yes | Key name in the OS keychain. Value is sent as Authorization: Bearer <value>. |
kind = "api_key"
auth {
kind = "api_key"
secret = "provider.api_key"
location = "header" # header, query, or cookie
name = "X-Api-Key"
}| Field | Required | Description |
|---|---|---|
kind | yes | "api_key" |
secret | yes | Key name in the OS keychain. |
location | yes | Where to send the key: "header", "query", or "cookie". |
name | yes | Header name, query parameter name, or cookie name. |
kind = "basic"
auth {
kind = "basic"
username = "{{ secrets.jira_email }}"
password_secret = "provider.password"
}| Field | Required | Description |
|---|---|---|
kind | yes | "basic" |
username | yes | Username string. Supports Jinja expressions, including secrets.* to pull from the keychain. |
password_secret | yes | Key name in the OS keychain whose value is the password. |
kind = "o_auth2_profile"
auth {
kind = "o_auth2_profile"
profile = "myprofile"
}| Field | Required | Description |
|---|---|---|
kind | yes | "o_auth2_profile" |
profile | yes | Name of an OAuth2 profile defined in ~/.config/earl/config.toml. Earl fetches, caches, and refreshes the access token automatically. |
See Secrets & Authentication for OAuth2 profile configuration.
result block
result {
decode = "json"
extract = { json_pointer = "/items" }
output = "Found {{ result | length }} items."
result_alias = "items"
}| Field | Type | Default | Description |
|---|---|---|---|
decode | string | "auto" | How to parse the response body before the output template runs. |
extract | block | — | Extract a subset of the decoded response. |
output | string | "{{ result }}" | Jinja template rendered to produce the final output. The variable result holds the decoded (and optionally extracted) response. |
result_alias | string | — | Rename the result variable in the output template. result_alias = "items" makes the response available as {{ items }} instead of {{ result }}. |
Decode modes
| Value | Description |
|---|---|
"auto" | Infers mode from Content-Type header, then falls back to JSON detection on the body. |
"json" | Parse body as JSON. result is the decoded object or array. |
"text" | Treat body as UTF-8 text. result is a string. |
"html" | Treat body as HTML text. result is a string. |
"xml" | Treat body as XML text. result is a string. |
"binary" | Return raw bytes. result is the bytes base64-encoded as a string. |
extract options
Extract runs after decode and before the output template. Use it to pull a specific value out of a large response.
# JSON Pointer (RFC 6901)
extract = { json_pointer = "/data/users" }
# Regular expression — result is the first capture group
extract = { regex = "token=([A-Za-z0-9]+)" }
# XPath — for XML responses
extract = { xpath = "//user/name/text()" }
# CSS selector — for HTML responses
extract = { css_selector = "h1.title" }transport block
The transport block is optional and available for all protocols except SQL (where it applies at connection level). It controls low-level network behavior.
transport {
timeout_ms = 30000
max_response_bytes = 8388608
redirects {
follow = true
max_hops = 5
}
retry {
max_attempts = 3
backoff_ms = 250
retry_on_status = [429, 503]
}
compression = true
tls {
min_version = "1.2" # 1.0, 1.1, 1.2, or 1.3
}
proxy_profile = "corporate"
}| Field | Type | Default | Description |
|---|---|---|---|
timeout_ms | integer | 30000 | Request timeout in milliseconds. |
max_response_bytes | integer | 8388608 (8 MiB) | Maximum response body size. Clamped between 1 KiB and 128 MiB. |
redirects.follow | boolean | true | Whether to follow HTTP redirects. |
redirects.max_hops | integer | 5 | Maximum redirect chain length. |
retry.max_attempts | integer | 1 | Total attempts including the first. Values below 1 are treated as 1. |
retry.backoff_ms | integer | 250 | Delay between retries in milliseconds. |
retry.retry_on_status | list of integers | [] | HTTP status codes that trigger a retry (e.g. [429, 503]). |
compression | boolean | true | Whether to accept compressed responses. |
tls.min_version | string | — | Minimum TLS version: "1.0", "1.1", "1.2", or "1.3". |
proxy_profile | string | — | Name of a proxy profile defined in ~/.config/earl/config.toml. |
environment_overrides block
When an environment is active, its override replaces the command's default operation (and optionally result) entirely.
environment_overrides {
staging {
operation {
protocol = "http"
method = "GET"
url = "https://staging.example.internal/api/items"
auth {
kind = "bearer"
secret = "myapp.staging_token"
}
}
result {
decode = "json"
output = "[staging] {{ result | length }} items"
}
}
}Each named block inside environment_overrides corresponds to an environment name defined in the provider's environments block. See Environments for how environments are configured and activated.
The result override inside an environment is optional. If omitted, the command's default result block applies.
environments block (provider-level)
environments {
default = "production"
secrets = ["myapp.prod_token"]
production {
base_url = "https://api.myapp.com"
}
staging {
base_url = "https://staging.myapp.com"
}
}| Field | Type | Description |
|---|---|---|
default | string | Environment name that is active when none is specified. |
secrets | list of strings | Secrets resolved before environment variable values are rendered. |
environments | map | Named environments. Each environment is a map of string variable names to Jinja template strings. Those variables are available in operation fields as {{ vars.base_url }} etc. |
See Environments for the complete guide.