Skip to main content
Version: 1.0.0

Tutorial - A public API tool with the Touba SDK

This project makes a real HTTP read against Données Québec. It needs the Touba distribution, Node.js 24, npm and internet access. It uses no API key or paid model. A network failure remains an error, not a fabricated catalogue result.

1. Install the project SDK​

See SDK installation. In a macOS/Linux terminal:

Terminal
mkdir -p public-api-demo
cd public-api-demo
mkdir -p handlers vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz

2. Declare the typed tool​

datasets.tb
module examples.search

type DatasetSearchRequest
query is Text

type DatasetSearchResult
count is Int
first_title is Text

tool SearchDatasets
description "Search the public Données Québec dataset catalogue."
takes request as DatasetSearchRequest
gives DatasetSearchResult
requires network

skill SearchPublicDatasets
description "Return the number of matches and the first dataset title."
takes request as DatasetSearchRequest
gives DatasetSearchResult
uses SearchDatasets
do
SearchDatasets
with request as request
into result
give result

3. Implement the HTTP request​

The official API guide documents package_search. The handler supplies q and limits returned records with rows=1. The catalogue's total match count is distinct from that row limit.

handlers/search_datasets.handler.mjs
import { defineTool, ToubaHandlerError } from "@touba/sdk";

await defineTool({
toolRef: "tool:examples.search/SearchDatasets",
inputSignature: [{ name: "request", type_ref: "type:examples.search/DatasetSearchRequest" }],
outputType: "type:examples.search/DatasetSearchResult",
requiredGrants: ["network"],
run: async ({ request }) => {
const url = new URL("https://www.donneesquebec.ca/recherche/api/3/action/package_search");
url.searchParams.set("q", request.query);
url.searchParams.set("rows", "1");
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!response.ok) throw new ToubaHandlerError("DatasetHttpError", "The catalogue request failed.");
const payload = await response.json();
if (payload.success !== true || !Number.isSafeInteger(payload.result?.count)
|| !Array.isArray(payload.result?.results)) {
throw new ToubaHandlerError("DatasetResponseInvalid", "The catalogue response has an unexpected shape.");
}
const first = payload.result.results[0];
const title = first?.title ?? first?.name ?? "No matching dataset";
if (typeof title !== "string") throw new ToubaHandlerError("DatasetTitleInvalid", "A dataset title must be text.");
return { count: payload.result.count, first_title: title };
},
});

4. Declare the host binding​

This script writes complete manifests with the actual absolute Node path. The first explicitly grants network access; the second intentionally denies it. The operation reads data and declares kind: none, not an external write.

configure.mjs
import { writeFileSync } from "node:fs";
const manifest = {
manifest_version: "touba-manifest-v1", bindings: {
"tool:examples.search/SearchDatasets": {
handler: { kind: "process", command: process.execPath, args: ["handlers/search_datasets.handler.mjs"], cwd: "." },
grants: ["network"], effect_contract: { kind: "none" },
timeout_ms: 20000, max_payload_bytes: 65536,
payload_constraints: { input: { "request.query": { non_empty: true, max_length: 120 } } },
},
},
};
writeFileSync("touba.manifest.json", JSON.stringify(manifest, null, 2) + "\n");
manifest.bindings["tool:examples.search/SearchDatasets"].grants = [];
writeFileSync("denied.manifest.json", JSON.stringify(manifest, null, 2) + "\n");

touba manifest generate can also produce a draft, but does not choose a grant or effect contract for you. A generated effect_contract: null is not executable. The runtime never discovers a manifest implicitly.

5. Execute and verify the refusal​

verify.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const result = JSON.parse(readFileSync("result.json", "utf8"));
assert.equal(result.status, "success");
assert.ok(Number.isSafeInteger(result.result.count) && result.result.count >= 0);
assert.equal(typeof result.result.first_title, "string");
const refused = JSON.parse(readFileSync("refused.json", "utf8"));
assert.equal(refused.reason, "ToolCapabilityNotGranted");
assert.equal(refused.trace.events.filter(event => event.kind === "tool_binding_authorized").length, 0);
console.log(JSON.stringify(result.result));
console.log("PUBLIC_API_READ_AND_GRANT_REFUSAL_OK");
Terminal
node configure.mjs
touba compile datasets.tb --out datasets.air.json
touba run datasets.air.json --entry skill:examples.search/SearchPublicDatasets \
--manifest touba.manifest.json --inputs-json '{"request":{"query":"pompier"}}' \
--output json --trace-out api.trace.json > result.json
if touba run datasets.air.json --entry skill:examples.search/SearchPublicDatasets \
--manifest denied.manifest.json --inputs-json '{"request":{"query":"pompier"}}' \
--output json > refused.json; then
exit 1
else
test "$?" -eq 1
fi
node verify.mjs

Expected: PUBLIC_API_READ_AND_GRANT_REFUSAL_OK. The count and first title may change as the public catalogue evolves. result.json contains business data; the trace records the tool's execution without copying the response body. The denied run exits with 1 and never authorizes the handler.

The runtime checks the input schema and payload constraints before dispatch, and the output schema before publishing the value. A grant is not an OS sandbox. For artifact integrity, continue with the supply-chain tutorial; a lock grants no new authority.