earl

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 more

Top-level fields

FieldTypeRequiredDescription
versionintegeryesSchema version. Must be 1.
providerstringyesProvider identifier. Used in earl call <provider>.<command>.
categorieslist of stringsnoLabels applied to every command in the file. Used for discovery and filtering.
environmentsblocknoProvider-level environment definitions. See Environments.
command "<name>"blockyesCommand 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 { ... }
}
FieldTypeRequiredDescription
titlestringyesShort display name shown in earl list.
summarystringyesOne-line description exposed to the agent in the MCP tool listing.
descriptionstringyesFull description the agent reads to decide whether to call the command. Supports Markdown.
categorieslist of stringsnoLabels for this specific command. Merged with provider-level categories for filtering.
annotationsblocknoMode, secrets list, and environment switching flags.
param "<name>"blocknoParameter declaration. Repeatable.
operationblockyesThe request to execute. Shape varies by protocol.
resultblocknoHow to decode and format the response. Defaults to decode = "auto" and output = "{{ result }}".
environment_overridesblocknoPer-environment operation replacements.

annotations block

annotations {
  mode    = "read"
  secrets = ["github.token"]
  allow_environment_protocol_switching = false
}
FieldTypeDefaultDescription
modestring"write""read" or "write". Write mode prompts for confirmation unless the caller passes --yes. Use "read" for commands that only retrieve data.
secretslist 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_switchingbooleanfalseWhen 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)"
}
FieldTypeRequiredDescription
typestringyesParameter type. See types table below.
requiredbooleannoWhether the caller must supply this parameter. Defaults to false.
defaultanynoDefault value used when the parameter is not supplied. Only valid when required = false.
descriptionstringnoShown to the agent so it knows what to pass.

Parameter types

TypeDescription
"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 { ... }
}
FieldTypeRequiredDescription
protocolstringyesMust be "http".
methodstringyesHTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
urlstringyesFull URL. Supports Jinja expressions.
pathstringnoPath segment appended to url. Useful when the base URL comes from an environment variable.
querymapnoQuery string parameters. Values support Jinja expressions.
headersmapnoRequest headers. Values support Jinja expressions.
cookiesmapnoCookies sent with the request. Values support Jinja expressions.
authblocknoAuthentication. See auth block.
bodyblocknoRequest body. See body block.
streambooleannoSet true to enable streaming. Defaults to false. See Streaming.
transportblocknoTimeout, 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:

FieldTypeRequiredDescription
protocolstringyesMust be "graphql".
urlstringyesGraphQL endpoint URL.
methodstringnoDefaults to POST. Override only if the server requires GET.
querymapnoURL query parameters (distinct from the GraphQL query).
headersmapnoRequest headers. Accept and Content-Type default to application/json.
cookiesmapnoCookies sent with the request.
authblocknoAuthentication.
graphqlblockyesThe GraphQL payload.
streambooleannoEnable streaming. Defaults to false.
transportblocknoTransport settings.

graphql inner block fields:

FieldTypeRequiredDescription
querystringyesThe GraphQL query or mutation. Heredoc syntax recommended for multi-line queries.
operation_namestringnooperationName sent in the request body. Required when the document contains multiple operations.
variablesmapnoVariables 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:

FieldTypeRequiredDescription
protocolstringyesMust be "grpc".
urlstringyesgRPC server URL.
headersmapnogRPC metadata headers.
authblocknoAuthentication.
grpcblockyesgRPC call configuration.
streambooleannoEnable server-streaming. Defaults to false.
transportblocknoTransport settings.

grpc inner block fields:

FieldTypeRequiredDescription
servicestringyesFully qualified service name, e.g. example.v1.ExampleService.
methodstringyesRPC method name, e.g. GetItem.
bodymapnoRequest message fields. Values support Jinja expressions.
descriptor_set_filestringnoPath 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:

FieldTypeRequiredDescription
protocolstringyesMust be "bash".
bashblockyesScript configuration.
streambooleannoStream stdout as lines. Defaults to false.

bash inner block fields:

FieldTypeRequiredDescription
scriptstringyesShell script to execute. Supports Jinja expressions.
envmapnoEnvironment variables set before the script runs. Values support Jinja expressions.
cwdstringnoWorking directory for the script.
sandboxblocknoResource and network limits.

bash.sandbox fields:

FieldTypeDefaultDescription
networkbooleanfalseWhether the script can make network requests.
writable_pathslist of strings[]Filesystem paths the script may write to.
max_time_msintegerWall-clock timeout in milliseconds.
max_output_bytesintegerMaximum combined stdout+stderr size in bytes.
max_memory_bytesintegerMemory limit in bytes.
max_cpu_time_msintegerCPU 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:

FieldTypeRequiredDescription
protocolstringyesMust be "sql".
sqlblockyesQuery configuration.
transportblocknoTransport settings (connection-level).

sql inner block fields:

FieldTypeRequiredDescription
connection_secretstringyesKey name in the OS keychain whose value is the database connection URL.
querystringyesSQL query with positional placeholders. Syntax varies by database: $1, $2... for PostgreSQL; ? for MySQL and SQLite.
paramslistnoValues 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.
sandboxblocknoQuery limits.

sql.sandbox fields:

FieldTypeDescription
read_onlybooleanRestrict the connection to read-only transactions.
max_rowsintegerLimit the number of rows returned.
max_time_msintegerQuery 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:

FieldTypeRequiredDescription
protocolstringyesMust be "browser".
browserblockyesBrowser configuration.

browser inner block fields:

FieldTypeDefaultDescription
stepslist of objectsyesOrdered list of step objects to execute. Each object must include an action field.
session_idstringStable identifier for a persistent browser session. Omit for a one-shot command.
headlessbooleantrueRun Chrome in headless mode.
timeout_msinteger30000Global timeout in milliseconds for the entire command.
on_failure_screenshotbooleantrueCapture 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:

FieldDefaultDescription
actionRequired. Identifies the step type. See step reference below.
optionalfalseWhen true, failure on this step is ignored and execution continues.
timeout_msPer-step timeout in milliseconds, overrides the command-level timeout.

Step reference

Navigation

ActionKey fieldsResult shape
navigateurl (required), expected_status, timeout_ms{"ok": true}
navigate_back{"ok": true}
navigate_forward{"ok": true}
reload{"ok": true}

Observation

ActionKey fieldsResult shape
snapshot{"text": "<accessibility tree>", "raw": [...]}
screenshotpath, type (png/jpeg), full_page, ref{"data": "<base64>", "path": "..."}
pdf_savepath{"path": "..."}

Interaction

ActionKey fieldsResult shape
clickref or selector, double_click{"ok": true}
hoverref or selector{"ok": true}
fillref or selector, text, submit{"ok": true}
fill_formfields (array of {ref/selector, value, type}){"ok": true}
select_optionref or selector, values (array){"ok": true}
checkref or selector{"ok": true}
uncheckref or selector{"ok": true}
press_keykey (e.g. "Enter", "Tab", "Escape"){"ok": true}
dragstart_ref/start_selector, end_ref/end_selector{"ok": true}
file_uploadref or selector, paths (array){"ok": true}
handle_dialogaccept (boolean), prompt_text{"ok": true, "accept": ...}

Mouse

ActionKey fieldsResult shape
mouse_movex, y{"ok": true}
mouse_clickx, y, button{"ok": true}
mouse_dragstart_x, start_y, end_x, end_y{"ok": true}
mouse_downx, y, button{"ok": true}
mouse_upx, y, button{"ok": true}
mouse_wheeldelta_x, delta_y{"ok": true}

Wait and assertions

ActionKey fieldsResult shape
wait_fortext, text_gone, time (seconds), timeout_ms (optional — defaults to command timeout_ms){"ok": true}
verify_text_visibletext{"ok": true}
verify_element_visiblerole, accessible_name{"ok": true}
verify_list_visibleitems (array){"ok": true}
verify_valuevalue{"ok": true}

JavaScript

ActionKey fieldsResult shape
evaluatefunction (JS arrow function string), ref{"value": ...}
run_codecode (JS statements string){"ok": true}

Cookies

ActionKey fieldsResult shape
cookie_listdomain (filter, optional){"cookies": [...]}
cookie_getname{"value": "..."}
cookie_setname, value, domain, path, expires, http_only, secure{"ok": true}
cookie_deletename{"ok": true}
cookie_clear{"ok": true}

Storage

ActionKey fieldsResult shape
local_storage_getkey{"value": "..."}
local_storage_setkey, value{"ok": true}
local_storage_deletekey{"ok": true}
local_storage_clear{"ok": true}
session_storage_getkey{"value": "..."}
session_storage_setkey, value{"ok": true}
session_storage_deletekey{"ok": true}
session_storage_clear{"ok": true}
storage_statepath (optional){"cookies": [...], "local_storage": {...}}
set_storage_statepath{"ok": true}

Tabs and viewport

ActionKey fieldsResult shape
tabsoperation (list/new/close/select), index{"tabs": [...]} or {"ok": true}
resizewidth, height{"ok": true}
close{"ok": true}

Network (session mode only)

ActionKey fieldsResult shape
routepattern, status, body, content_type{"ok": true}
route_list{"routes": [...]}
unroutepattern{"ok": true}
console_messages{"messages": [...]}
console_clear{"ok": true}
network_requests{"requests": [...]}
network_clear{"ok": true}
downloadpath{"path": "..."}

Recording

ActionKey fieldsResult shape
start_videowidth, height{"ok": true}
stop_video{"path": "..."}
start_tracing{"ok": true}
stop_tracing{"path": "..."}

Utility

ActionKey fieldsResult shape
generate_locatorref{"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:

FieldRequiredDescription
nameyesForm field name.
valueone of threeText content for the part. Supports Jinja expressions.
bytes_base64one of threeBase64-encoded binary content.
file_pathone of threePath to a file whose contents become the part body. Supports Jinja expressions.
content_typenoMIME type for the part.
filenamenoFilename 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"
}
FieldRequiredDescription
kindyes"bearer"
secretyesKey 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"
}
FieldRequiredDescription
kindyes"api_key"
secretyesKey name in the OS keychain.
locationyesWhere to send the key: "header", "query", or "cookie".
nameyesHeader name, query parameter name, or cookie name.

kind = "basic"

auth {
  kind            = "basic"
  username        = "{{ secrets.jira_email }}"
  password_secret = "provider.password"
}
FieldRequiredDescription
kindyes"basic"
usernameyesUsername string. Supports Jinja expressions, including secrets.* to pull from the keychain.
password_secretyesKey name in the OS keychain whose value is the password.

kind = "o_auth2_profile"

auth {
  kind    = "o_auth2_profile"
  profile = "myprofile"
}
FieldRequiredDescription
kindyes"o_auth2_profile"
profileyesName 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"
}
FieldTypeDefaultDescription
decodestring"auto"How to parse the response body before the output template runs.
extractblockExtract a subset of the decoded response.
outputstring"{{ result }}"Jinja template rendered to produce the final output. The variable result holds the decoded (and optionally extracted) response.
result_aliasstringRename the result variable in the output template. result_alias = "items" makes the response available as {{ items }} instead of {{ result }}.

Decode modes

ValueDescription
"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"
}
FieldTypeDefaultDescription
timeout_msinteger30000Request timeout in milliseconds.
max_response_bytesinteger8388608 (8 MiB)Maximum response body size. Clamped between 1 KiB and 128 MiB.
redirects.followbooleantrueWhether to follow HTTP redirects.
redirects.max_hopsinteger5Maximum redirect chain length.
retry.max_attemptsinteger1Total attempts including the first. Values below 1 are treated as 1.
retry.backoff_msinteger250Delay between retries in milliseconds.
retry.retry_on_statuslist of integers[]HTTP status codes that trigger a retry (e.g. [429, 503]).
compressionbooleantrueWhether to accept compressed responses.
tls.min_versionstringMinimum TLS version: "1.0", "1.1", "1.2", or "1.3".
proxy_profilestringName 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"
  }
}
FieldTypeDescription
defaultstringEnvironment name that is active when none is specified.
secretslist of stringsSecrets resolved before environment variable values are rendered.
environmentsmapNamed 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.

On this page