YAML or TypeScript?

A spec when a tool is one HTTP request; TypeScript when it needs logic. Both end up as the same kind of tool.

Where this fits: Concepts. After how Kervan works; it leads into the YAML and TypeScript groups of this book.

On this page

The same tool, both ways

The job: find a city by name and return the first match’s name, country and coordinates, from the public Open-Meteo geocoding API.

As a spec

Save it as geo.yaml and serve it with kervan run geo.yaml, or try it with kervan dev geo.yaml:

yaml
specVersion: 1
name: geo
version: 0.1.0
tools:
  - name: find_city
    title: Find city
    description: The first place with this name, and its coordinates.
    annotations: { readOnlyHint: true }
    input:
      type: object
      properties:
        name: { type: string, minLength: 2, maxLength: 100, description: "City name, e.g. Ankara" }
      required: [name]
    http:
      url: https://geocoding-api.open-meteo.com/v1/search
      query: { name: "{{input.name}}", count: 1, language: en, format: json }
    output:
      select: "results[0].{name: name, country: country, latitude: latitude, longitude: longitude}"
      schema:
        type: object
        properties:
          name: { type: string }
          country: { type: string }
          latitude: { type: number }
          longitude: { type: number }
        required: [name, country, latitude, longitude]

In the kervan dev geo.yaml inspector:

text
kervan> call find_city {"name": "Ankara"}
structured: {"name":"Ankara","country":"Republic of Türkiye","latitude":39.91987,"longitude":32.85427}

In TypeScript

The same tool in code, called in memory the way a client would. Run it with node geo.ts in a project made by pnpm try:new (or, once published, npm create kervan):

ts
import { createApp, ToolError, z } from "@kervan/core"
import { createTestClient } from "@kervan/transport/testing"

const app = createApp({ name: "geo", version: "0.1.0" })

const Place = z.object({
  name: z.string(),
  country: z.string(),
  latitude: z.number(),
  longitude: z.number(),
})

app.tool("find_city", {
  title: "Find city",
  description: "The first place with this name, and its coordinates.",
  input: z.object({ name: z.string().min(2).max(100).describe("City name, e.g. Ankara") }),
  output: Place,
  annotations: { readOnlyHint: true },
  handler: async ({ name }, ctx) => {
    const url = new URL("https://geocoding-api.open-meteo.com/v1/search")
    url.search = new URLSearchParams({ name, count: "1", language: "en", format: "json" }).toString()
    const response = await fetch(url, { signal: ctx.signal })
    if (!response.ok) throw new ToolError(`The geocoding API answered ${response.status}.`)
    const body = (await response.json()) as { results?: z.infer<typeof Place>[] }
    const first = body.results?.[0]
    if (!first) throw new ToolError(`No place is called "${name}".`)
    // Only the fields the model needs; never the whole API response.
    return { name: first.name, country: first.country, latitude: first.latitude, longitude: first.longitude }
  },
})

const client = await createTestClient(app)
const result = await client.callTool({ name: "find_city", arguments: { name: "Ankara" } })
const place = result.structuredContent as z.infer<typeof Place>
console.log(`first: ${place.name}, ${place.country}`)
await client.close()

Which one

AspectA spec (kervan.yaml)TypeScript
A tool isone HTTP request: URL, query, headers, body from templatesany code
Outputchosen with a JMESPath selectwhatever the handler returns
SSRF protection, secret redaction, a rate limit per toolbuilt inyours to add (or load a spec for those tools)
Secretsnamed in the spec, read from the environment, bound to hostsyour code reads them
Several calls, retries, branching, other protocolsnoyes
Changesedit the file; kervan run --watch reloads itedit the code; kervan dev restarts it
Testskervan dev inspector, or createTestClient with the loaded speccreateTestClient

Choose a spec when each tool maps to one request of an HTTP API and the response needs only picking, not computing. Choose TypeScript when a tool calls more than one thing, decides something, keeps state, or talks to something other than HTTP. Both are validated, timed and error-masked the same way (how Kervan works).

Limits of a spec

  • One request per tool, to an https URL whose scheme, host and port are fixed in the spec.
  • Templates substitute values ({{input.x}}, {{secrets.X}}); there are no conditions or loops.
  • Redirects are not followed unless allowed, and then each step is checked again.
  • No state between calls.

Anything past these is a TypeScript tool, and the two kinds can live in one server.