← Telemachus

リファレンス — sdk

SDK

The authoring surfaces a plugin, a tool or a workflow is written against. The durable product is the contract: a second implementation targets the same specs, APIs and security model.

define-tool

A tool is a declaration that expands to the OpenAI-compatible function schema the model sees. Parameters are required unless #:optional; #:enum, #:items and #:items-of (arrays of objects) are supported.

(define-tool doc_text
  #:description "Return the text of a repository document."
  (object string #:description "The repository object id")
  (version string #:optional #:description "A specific version id"))

(register-tool! "doc_text" doc_text "files:read"
                (lambda (conn principal args) ...))   ; -> a string or a jsexpr

The handler receives the database connection, the calling principal and the parsed arguments. It checks its own permission (require-perm) and meters its own AI spend (tenant-quota-record!); a result that is not a string is JSON-encoded at the agent boundary and kept as a value for a workflow step.

Artifact-shaped results

A handler may return a string, a JSON value, or an artifact — a result that names a thing rather than carrying it:

(artifact #:kind "document" #:title (hash-ref o 'key) #:summary "the filled form"
          #:content-type ct #:object-id (hash-ref o 'id) #:version-id (hash-ref o 'version_id)
          #:extra (hasheq 'object_id (hash-ref o 'id)))   ; keys a workflow step may bind

Kinds are document, table, text, link, image. The model receives one line ([document] inbox/acme.pdf.form.docx — the filled form (application/…) object_id=…) instead of the payload; the console renders a card that opens the document or follows the link; a workflow step keeps the whole value, so (out step result object_id) binds as before. Every document-pipeline tool returns one.

define-workflow

A workflow is a validated data spec; the macro emits it, and a JSON document posted to POST /api/workflows goes through the same validator. Unknown fields are rejected. Bindings are the frozen sublanguage: (in name), (out step path…), (item path…), (index), (locale), (run-id), (team-id), (user-id), and seven predicates for choice.

(define-workflow process-upload
  #:input ([object_id string] [schema object] [template string] [locales array])
  (step text   (tool doc_text #:object (in object_id)))
  (step fields (tool doc_extract_fields #:text (out text result) #:schema (in schema)
                                        #:object (in object_id) #:run (run-id) #:step "fields")
               #:retry 1)
  (step has_form (choice (empty (in template)) #:then translate_source #:else form))
  ...)

Every step is a scheduler job: durability, cancellation, quota admission and the org gate are inherited. map fans out over a list, one job per item. The shipped workflows are in workflows.md; the format is served at GET /api/workflows/schema.

The blob store

register-blob-store! name (hash 'put! 'get 'delete! 'stat). The store is handed a namespace (the org id) and a SHA-256 digest, never a principal — a backend has no authorization to get wrong. TELEMACHUS_BLOB_STORE selects one.

The onboarding provider

register-onboarding! name experience contributes a beta funnel experience document (fields, copy, theme, judge prompt). The published one in the database wins over any registered default.

The browser SDK

/beta-sdk.js exposes window.Telemachus.betaconfig(), challenge(), signup(fields) — for a Tier-B bundle; a Tier-C template gets the same three endpoints over CORS from its sandboxed iframe.

Plugin routes

A plugin may contribute authenticated HTTP endpoints — the API a Tier-B onboarding bundle or an app-specific console calls:

(define (word-count-route conn principal args)          ; args: {params, query, body}
  (define text (hash-ref (hash-ref args 'query) 'text #f))
  (unless (string? text) (raise-user-error 'word_count "text is required"))   ; -> 400
  (hasheq 'words (length (string-split text))))               ; -> 200 JSON

(define routes
  (list (list "GET" "/word-count" "chat:use" word-count-route "Count the words in ?text=.")))

The platform mounts it at /api/x/<plugin-id>/word-count, so a plugin can never shadow a core route. Every plugin route requires a bearer token; the named permission is checked through can? before the handler runs, exactly as a tool's is. A malformed entry fails the plugin's load.

Plugin bundles

A plugin serves its own screens from two directories, and the difference is who may read them:

Directory Served at Who
landing/ /beta/bundle/<id>/ anyone — the Tier-B onboarding funnel, a public page a prospect is linked to, cached public, max-age=300
bundle/ /api/x/<id>/bundle/ a caller with a bearer token; Cache-Control: private, no-store and Vary: Authorization, so no shared cache ever holds one

bundle/ is reserved by the platform under every plugin's prefix, and core routes match before plugin ones, so a plugin cannot take that path for something else. The path is resolved segment by segment against the loaded plugin's directory: it can never climb out of bundle/ into the plugin's source, and it can never reach a directory nobody installed. The bundle is code — one copy for everyone; anything that varies by organisation comes from the plugin's own API routes, which see the principal and the org. Assets must ship in the bundle: the console's Content-Security-Policy is same-origin, so a CDN script or font is blocked.

plugins/example-tools/bundle/index.html is the worked example — a screen that calls its own /api/x/example-tools/word-count route.

Job kinds

A plugin may add background work of its own. init! runs at load with full SDK access, and that is the documented route:

(define (word-count-job conn principal payload)       ; -> a jsexpr result
  (hasheq 'words (length (string-split (hash-ref payload 'text "")))))

(define (init!)
  (register-job-kind! "x.example-tools.word-count" word-count-job))

The name is platform-fixed: x.<plugin-id>.<name>, the same rule plugin routes follow, enforced while the plugin's init! runs — so a plugin can never take a core kind's name (flow.step, infer.chat) or another plugin's, and a violation fails that plugin's load rather than surfacing at claim time.

A plugin kind is an ordinary job. It inherits everything the row carries, not anything from the plugin: the enqueuing team and user, the per-team concurrency cap and quota admission at claim, the org gate on whatever the handler touches, cancellation, and the lease that returns it to the queue if the worker dies. enqueue-job! submits one; GET /api/jobs lists it like any other.

A remote kind (#:remote? #t) has no in-process handler — it is claimed by a pull executor over HTTP — and must carry #:validate, a procedure returning a string naming the problem or #f. It is the only thing standing between a worker's reply and the rest of the system, so it is required, not optional.

The route table

server/routes.rkt declares every HTTP route with its permission, authentication, feature flag and one line of documentation; the server refuses to boot if the table and the handlers disagree. api.md is rendered from it.