← Tidelines/Guides

How to connect an agent tool to a real API

Everybody writes the rule that says "call the order lookup." Far fewer write down what the order lookup actually is, what it takes, and which endpoint it hits, in the same file, under the same check. Here is how the wiring goes, and the three ways it comes loose.

by TypeGlish team8 min read#guides
Name here. Endpoint there.

TL;DR Declare a tool the host already owns with $IMPORT tool; define one yourself with $TOOL plus a - request:: row that binds it to an HTTP method and URL, and put the base URL and auth header in a $SERVICE so every tool shares one copy. Reference it from a rule as @[name], never as bare prose, and run typeglish build --bundle to see exactly what the runtime receives and how little of it the model does.

A support agent without tools is a FAQ with better manners. The moment it can look up an order, open a ticket, or check a shipment, it becomes worth deploying, and the prompt stops being a document about tone and starts being an integration. That integration usually lives in three places at once: the tool schema in your agent platform, the endpoint in some backend service, and a line in the prompt telling the model when to call it. Three places, three owners, no shared check. TypeGlish lets you put the whole thing in one file the compiler reads, so a rename in the schema and a rule that still uses the old name is a red build instead of a Monday.

§1Two ways to hand an agent a tool

Start by deciding who owns the tool. If your agent platform already holds the registry (you wired the function up in a console, the runtime dispatches it), you are not defining anything here: you are declaring that it exists so the compiler can check your references against it. That is $IMPORT tool.

support.tg - the host owns the tool✓ compiles
<$CONFIG>
  $IMPORT tool escalate_ticket
</$CONFIG>

# Role
You are a support agent for Acme, a SaaS analytics company.

# Constraints
- MUST call @[escalate_ticket] when a customer asks for a manager.
- MUST keep every response to at most 3 sentences.
One line of declaration buys you a checked name. The compiler now knows escalate_ticket is a tool, so @[escalate_ticket] resolves and a typo in it does not.

The other case is the one this guide is really about: you want the tool defined next to the rules that use it, versioned in the same file, reviewed in the same pull request. That is $TOOL, and it carries a description, typed inputs, and the request it makes.

§2The binding: one tool, one request line

A $TOOL block is a record: indented - field:: value rows beneath the header. Three of them matter. The description is the only part of the tool the model reasons over, so write it as guidance for when to call, not as API documentation. The input rows are the parameters, typed with the same English type grammar as everything else. And request is the binding: a method, then a URL.

support.tg - defined here, bound to an endpoint✓ compiles
<$CONFIG>
  $TOOL lookup_order
    - description:: Looks up one order by its id. Use it after the customer gives an order number.
    - input::
      - order_id:: string
    - request:: GET https://api.acme.com/orders/@{order_id}
</$CONFIG>

# Role
You are a support agent for Acme, a SaaS analytics company.

# Constraints
- MUST call @[lookup_order] once the customer gives an order number.
- MUST keep every response to at most 3 sentences.
The @{order_id} in the path is not a runtime variable you have to declare elsewhere. It is the tool's own input, filled from the call. Indentation is load-bearing inside a block: author those rows flat at the header column and the block dissolves into prose.

§3The service: base URL and auth, written once

One tool with an absolute URL is fine. Four tools against the same backend, each repeating the host and the Authorization header, is the same copy-paste problem you already solved in code. $SERVICE is the shared backend: a base and a header block, declared once, merged into every tool that requests through it by name.

support.tg - two tools, one service✓ compiles · A (93/100)
<$CONFIG>
  $SERVICE crm
    - base:: https://api.acme.com/v2
    - headers::
      - Authorization:: Bearer @{env.ACME_KEY}
      - Accept:: application/json

  $TOOL lookup_order
    - description:: Looks up one order by its id. Use it after the customer gives an order number.
    - input::
      - order_id:: string
    - request:: GET crm /orders/@{order_id}

  $TOOL create_ticket
    - description:: Opens a support ticket for a human to pick up. Use it when the issue needs a person.
    - input::
      - order_id:: string
      - team:: one of billing, technical, sales
      - priority:: optional one of low, normal, high
    - request:: POST crm /tickets
</$CONFIG>

# Role
@@ role: scope the agent so its tool calls have a domain
You are a support agent for Acme, a SaaS analytics company.

# Constraints
@@ ask_first: the id is the tool's only required input, so get it before calling
- ALWAYS ask for the order number before you answer a question about an order.
@@ lookup: one checked pointer, so a rename of the tool turns the build red
- MUST call @[lookup_order] once the customer gives an order number.
@@ handoff: the agent opens the ticket rather than promising a callback it cannot make
- WHEN a customer asks for a human THEN call @[create_ticket] and tell the customer the ticket number.
@@ brevity: three sentences keeps support replies scannable
- MUST keep every response to at most 3 sentences.
The credential appears as a name, @{env.ACME_KEY}, never as a value. team is a closed set, so the model cannot invent a fourth queue, and priority is optional. Note that $-commands take // comments, not @@ notes: a @@ above a command is structure/unattached-annotation, a blocking error, because an annotation documents the statement directly below it and a command is not a statement.
tg check & tg score - output
$ npx typeglish check support.tg
✓ 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score support.tg
support.tg — A (93/100)  proven errors: none  tiers: base+z3
  planes  runtime 91 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 68 x.21 · hardness 100 x.12 · directness 98 x.08 · consistency 100 x.17
          · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
Two tools, two bindings, one service, and the whole integration passes the same gate as the prose. The remaining lever is enforceability, honestly: "asks for a human" leans on the model reading intent, which is the part a tool cannot decide for you.

§4What each reader gets

Now the part that surprises people. Build the file with --bundle and you get two outputs, for two different consumers. The prompt artifact is what the model reads. The agent bundle is what your runtime reads. They share almost nothing.

.typeglish/dist/support.txt - what the model reads
# Role
You are a support agent for Acme, a SaaS analytics company.

# Constraints
- ALWAYS ask for the order number before you answer a question about an order.
- MUST call lookup_order once the customer gives an order number.
- WHEN a customer asks for a human THEN call create_ticket and tell the customer the ticket number.
- MUST keep every response to at most 3 sentences.
No URL. No header. No parameter list. No @@ notes. The @[create_ticket] pointer compiled down to the bare name, which is all the model needs to select a tool: the runtime owns the calling convention. The whole <$CONFIG> section folded away, which is the same compile-time seam described in your system prompt has a compile time.
.typeglish/dist/support.agent.json - what the runtime reads (excerpt)
{
  "tools": [
    {
      "name": "create_ticket",
      "description": "Opens a support ticket for a human to pick up. Use it when the issue needs a person.",
      "params": [
        { "name": "order_id", "required": true, "type": "string" },
        { "name": "team", "required": true, "type": "string",
          "enum": ["billing", "technical", "sales"] },
        { "name": "priority", "required": false, "type": "string",
          "enum": ["low", "normal", "high"] }
      ],
      "binding": {
        "method": "POST",
        "url": "https://api.acme.com/v2/tickets",
        "headers": [
          { "name": "Authorization", "value": "Bearer @{env.ACME_KEY}" },
          { "name": "Accept", "value": "application/json" }
        ],
        "service": "crm"
      }
    }
  ]
}
The service resolved: POST crm /tickets became the full URL with both headers merged in, and one of billing, technical, sales became a JSON enum. The @{env.ACME_KEY} pointer is still a pointer, because the bundle is a build artifact you commit to nothing and your host resolves at dispatch. The $IMPORT tool version from §1 produces the same bundle with an empty tools array, which is exactly right: you declared a tool you do not own.

§5The three ways the wiring comes loose

Each of these is a real failure we have watched happen in a support prompt, and each one is a diagnostic rather than a production incident. Run them yourself; they take a temp file.

The tool name in bare prose. Someone writes the rule the obvious way and never points it. The compiler will not guess that a bare word in a sentence is a reference, so the rule and the tool are two unrelated facts sitting in one file.

bare.tg✗ won't build
<$CONFIG>
  $TOOL lookup_order
    - description:: Looks up one order by its id.
    - input::
      - order_id:: string
    - request:: GET https://api.acme.com/orders/@{order_id}
</$CONFIG>

# Role
You are a support agent for Acme.

# Constraints
- MUST call lookup_order once the customer gives an order number.
lookup_order here is a word, not a reference. Rename the tool tomorrow and this line still reads fine to a human and means nothing to the compiler.
tg check - output
bare.tg:13:13  error  structure/bare-tool-ref  "lookup_order" is a tool, but this mention is
  bare prose — the compiler cannot bind it. Point it with @[lookup_order] (a checked
  reference), or quote it ("lookup_order") to speak the name as plain text.

 1 file — 1 error, 0 warning, 0 info
The escape hatch matters as much as the fix. Sometimes you really do want the agent to say a name out loud, and quoting it says so.

The service that is not there. A tool requests through a service name that no $SERVICE block defines, usually because the block was renamed or lifted into another file. Without the check, this fails at dispatch with a URL of wms/orders/... and a 404 nobody can trace back to the prompt.

dangling.tg✗ won't build
<$CONFIG>
  $TOOL lookup_order
    - description:: Looks up one order by its id.
    - input::
      - order_id:: string
    - request:: GET wms /orders/@{order_id}
</$CONFIG>

# Role
You are a support agent for Acme.

# Constraints
- MUST call @[lookup_order] once the customer gives an order number.
Every request that names a service is checked against the services this file declares.
tg check - output
dangling.tg:6:5  error  structure/dangling-service  Request references $SERVICE "wms",
  which is not defined.

 1 file — 1 error, 0 warning, 0 info
Named and located, at the request row rather than at the missing block, because the request row is the line you have to change.

The tool nobody calls. A capability was declared and then the rule that used it got rewritten, or was never written at all. The agent has an escalation tool and no instruction that reaches it, which reads in production as an agent that simply will not hand off.

orphan.tg⚠ warns · errors under --strict
<$CONFIG>
  $IMPORT tool escalate_ticket
</$CONFIG>

# Role
You are a support agent for Acme.

# Constraints
- MUST escalate to a human agent when a customer asks for a manager.
The rule says escalate. It just never points at the thing that escalates, so the model is left to escalate by whatever means it can imagine, which is usually an apology.
tg check, then tg check --strict - output
$ npx typeglish check orphan.tg
orphan.tg:2:3  warn   structure/unused-import  Imported tool "escalate_ticket" is never used.

✓ 1 file — 0 error, 1 warning, 0 info

$ npx typeglish check orphan.tg --strict
orphan.tg:2:3  error  structure/unused-import  Imported tool "escalate_ticket" is never used.

 1 file — 1 error, 0 warning, 0 info
A plain check lets this through as a warning. In CI, run --strict and it stops the build: an unreachable tool is not a style preference. This is the same warning that hid a real handoff bug in your agent said the wrong thing, now what?.
Field note

The whole point of the @[name] bracket is that it is typed: {} is a variable, <> a section, [] a tool, and a pointer whose target does not exist is a compile error rather than a blank space in a shipped prompt. That is the contract argument from your system prompt is an API contract, extended to the wire. If the symptom you actually have is an agent that has a perfectly good tool and stubbornly will not use it, the defect is usually in the description and the trigger rule rather than the binding: see why your agent won't use the tool you gave it. And keep the credential a name: a real key in the file is a blocking error that also caps the score at F, which is spelled out in why your agent will read out your API key. Every marked figure on this page is re-run against the real compiler by the CI guard on every build.

FAQCommon questions

What is the difference between $IMPORT tool and $TOOL?
$IMPORT tool declares that the host runtime already owns a tool, so TypeGlish only checks that your prompt references it correctly and the agent bundle comes back with an empty tools array. $TOOL defines the tool here: name, description, typed parameters, and an HTTP binding. Use $IMPORT tool when your agent platform holds the tool registry, and $TOOL when you want the schema versioned in the same file as the rules that call it.
How do I bind an agent tool to an HTTP endpoint?
Add a request row to the $TOOL block. It takes a method and a URL, either absolute (GET https://api.acme.com/orders/@{order_id}) or through a named $SERVICE (GET crm /orders/@{order_id}), and an @{param} pointer in the path is filled from the tool's own declared input. Run typeglish build --bundle and the compiled binding, with the merged base URL and headers, lands in the agent bundle beside the tool schema.
Where should the API key for an agent tool live?
In the host environment, referenced by name from a $SERVICE header as @{env.ACME_KEY}. The prompt file then carries the name of the credential and never its value, so the file is safe to commit and safe to share in a review. A literal key in the prompt is a blocking security/leaked-secret error and caps the TG score at F.
Why does my prompt fail with structure/bare-tool-ref?
Because a rule spells the tool's name in plain prose, so the compiler cannot tell a reference from a coincidence. Point it with @[lookup_order] and the reference is checked, which means renaming or deleting the tool turns the build red instead of shipping a rule that calls something that no longer exists. If you genuinely meant to say the name out loud to a customer, quote it as "lookup_order" and it stays plain text.
∿ washed up Jul 29, 2026 ∿