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:
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
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.
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.
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
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");
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.