GitHub Repos

A research agent's first step: find the candidates and keep only what you need from each. This one searches GitHub for repositories and returns names, stars and descriptions, most-starred first.

function searchRepos({ query = 'tosijs', perPage = 5 }) {
  let url =
    'https://api.github.com/search/repositories?q=' +
    encodeURIComponent(query) +
    '&per_page=' +
    perPage +
    '&sort=stars'
  let response = httpFetch({ url })
  if (response.items == null) {
    return {
      total: 0,
      repos: [],
      error: response.message ?? 'unexpected response',
    }
  }
  let repos = response.items.map((x) => ({
    name: x.full_name,
    stars: x.stargazers_count,
    description: x.description,
  }))
  return { total: response.total_count, repos }
}

httpFetch is the built-in fetch atom. It returns the response body, parsed as JSON when the server sends JSON. It does not return the HTTP status or a Response object. Besides url it accepts method, headers and body; httpFetch({ url, headers: { Accept: 'text/plain' } }) is a valid call.

The agent's parameters are the run's arguments: the host passes an object such as { query: 'language:rust', perPage: 10 }, and the defaults apply to whatever it leaves out.

What to notice

A variation

One repository's vital signs:

function repoInfo({ owner = 'tonioloewald', repo = 'tjs-lang' }) {
  let r = httpFetch({
    url:
      'https://api.github.com/repos/' +
      encodeURIComponent(owner) +
      '/' +
      encodeURIComponent(repo),
  })
  if (r.full_name == null) {
    return { error: r.message ?? 'not found' }
  }
  return {
    name: r.full_name,
    stars: r.stargazers_count,
    language: r.language,
    openIssues: r.open_issues_count,
  }
}

The owner and repository names are encoded too. A real GitHub name never needs it, but the arguments come from whoever runs the agent, and a repo of 'x/../../users/someone' would otherwise point the request at a different endpoint.

A missing repository does not make httpFetch fail. GitHub answers 404 with a JSON body ({ "message": "Not Found", … }), and, as above, httpFetch hands the agent that body, not the status. With repo: 'no-such-repo' this agent returns { error: 'Not Found' }. The if is how you tell.

The gotchas

Where the token goes instead

A host that needs authenticated requests puts the credential in its own fetch capability, a function the host passes to vm.run. The agent calls httpFetch exactly as above. The VM checks the request (the scheme, the method, the agent's headers against a fixed list, and the domain against the allowlist) and then calls the capability with it, including any headers the agent set. The capability adds the token on the way out, so the agent can use the token without ever holding it. This sample is Node or Bun code (it reads process.env):

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const token = process.env.GITHUB_TOKEN
if (!token) throw new Error('GITHUB_TOKEN is not set')

// Called with the request after the VM's checks. Returns plain JSON data.
const githubFetch = async (url, { method, headers, body, signal }) => {
  const h = new Headers(headers)
  h.set('Accept', 'application/vnd.github+json')
  // set() replaces an Authorization header the agent sent, in any spelling
  h.set('Authorization', `Bearer ${token}`)
  const res = await fetch(url, {
    method,
    signal,
    headers: h,
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  return res.json()
}

// agentSource: the repoInfo agent above, as a string
const agentSource = `function repoInfo({ owner = 'tonioloewald', repo = 'tjs-lang' }) { … }`

// Transpile on your side: vm-ast runs ASTs only. Passing the VM's atoms lets the
// transpiler check every atom call against the atom's declared inputs.
const vm = new AgentVM()
const { ast } = transpile(agentSource, { atoms: vm.atoms })
const run = await vm.run(
  ast,
  { owner: 'tonioloewald', repo: 'tjs-lang' }, // the agent's arguments
  {
    fuel: 1000, // the run's work budget; this agent uses about 7
    capabilities: { fetch: githubFetch }, // the IO functions the agent may reach
    context: { allowedFetchDomains: ['api.github.com'] }, // policy the VM enforces
  }
)

if (run.error) console.error(run.error.message)
else console.log(run.result) // { name: 'tonioloewald/tjs-lang', stars: …, … }

The two option bags do different jobs. capabilities holds the functions that do the IO. context holds the run's policy, which the VM applies before any capability is called, so the allowlist goes there and not inside your function. Fuel is the run's budget for work: each step costs a little and a fetch costs 5, so 1000 is generous for one request. A 100-result search uses about 26, because mapping more items is more work. See Fuel Limits.

What the allowlist does depends on which fetch the run uses:

If the capability throws, the agent sees an ordinary error: try/catch in the agent catches it, and the caught value is the error's message as a string. Do not put anything secret in that message. Without a try, the run ends with that message as its error. This sample does not check res.ok, because GitHub's error responses are JSON bodies the agents above already handle; a capability for an API that answers errors with HTML should check the status and return or throw something the agent can read.

Return data, not the Response. A live host object cannot cross into the agent: a capability that returns one ends the run. Capabilities and Custom Atoms covers what a capability may return and has a fuller safe fetch.