Skip to content

Skill Fulfillment Contract v1

The Skill Fulfillment Contract v1 is the wire contract Sill honors when your own HTTPS endpoint fulfills one of the five read-side default skills. It covers the two directions of a call:

  • Request — the JSON body Sill POSTs to your endpoint, HMAC-signed with the shared secret you minted for the skill.
  • Response — the JSON body your endpoint returns, which Sill validates against a canonical schema before passing the (sanitized) data to the agent.

Every schema is a plain JSON Schema (bounded dialect: type, required, properties, additionalProperties, minLength / maxLength, minimum / maximum, enum, items, minItems / maxItems). No $ref, no oneOf, no format — you only need a plain JSON reader to work with them.

The five default skills Sill can route to your endpoint are:

SkillPurpose
browse_catalogSearch and browse your product catalog.
check_availabilityReal-time stock lookup for a SKU.
order_statusLook up an order’s status. Customer-scoped.
track_shipmentGet shipment tracking for an order. Customer-scoped.
recommendRecommend products from your catalog.

The two transactional default skills (place_order, request_refund) are not part of this contract — they carry a signed payment authorization and settle through Sill’s own payment path.

The most important rule in this contract sits on two skills: order_status and track_shipment. Sill sends customer_email as a required argument on every call. Your handler MUST verify that the email belongs to the order before returning any data. Sill cannot enforce this for you — only your customer database knows which email owns which order.

If the email does not match, respond one of two ways:

  • HTTP 404 with an empty body, or
  • HTTP 200 with a canonical result that omits customer-scoped detail (see each skill page for the exact shape).

Do NOT reveal an order to a caller whose email argument does not match. Doing so is the same class of bug as writing your own storefront order-lookup form and forgetting the check — an agent that guesses an order number can then read the order.

The order_status and track_shipment pages open with a concrete correct-vs-wrong example.

For the three non-customer-scoped skills (browse_catalog, check_availability, recommend), Sill does not send customer_email and your handler does not need to scope the response to a specific customer — the data is public catalog information.

Sill enforces two invariants on every response before your bytes reach the agent:

  1. Shape validation. Your response is checked against the canonical response schema for the skill. An extra top-level field (for example, customer_address on an order_status response) is rejected — this stops a well-meaning handler from over-sharing. additionalProperties: false applies at every object depth.
  2. Prompt-injection sanitize. The final agent-bound payload is stripped of common prompt-injection markers, secret patterns, XSS markup, and Unicode-tag blocks. If the sanitizer rejects, the agent sees a bounded error rather than your raw bytes.

Beyond those two, Sill does not inspect the semantics of your response. You are the source of truth for order state, catalog content, and inventory.

Every request Sill POSTs to your endpoint carries a X-Sill-Signature header:

X-Sill-Signature: t=<unix-seconds>,v1=<hex>
  • t is a Unix timestamp in seconds — the time Sill signed the request.
  • v1 is the HMAC-SHA256 of the string "<t>.<raw-body>", keyed with the shared secret shown to you when you saved the skill, hex-encoded.

Your handler MUST:

  1. Read the RAW request bytes (do not re-serialize a parsed JSON — you must sign over the exact bytes received).
  2. Parse the header, recompute the HMAC, and compare in constant time (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal).
  3. Reject stale requests — refuse anything older than ~5 minutes (abs(now - t) > 300).
  4. On mismatch, return HTTP 401 with an empty body. Do not leak the reason.

If verification passes, parse the body as JSON and dispatch to the skill handler.

import { createHmac, timingSafeEqual } from 'node:crypto';
const SILL_SECRET = process.env.SILL_SKILL_SECRET as string;
const MAX_AGE_SECONDS = 5 * 60;
export function verifySillSignature(
header_value: string,
raw_body: string,
now_seconds = Math.floor(Date.now() / 1000),
): boolean {
let t = -1;
let sig = '';
for (const part of header_value.split(',')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq);
const v = part.slice(eq + 1);
if (k === 't') t = Number(v);
else if (k === 'v1') sig = v;
}
if (!Number.isFinite(t) || t < 0 || sig.length === 0) return false;
if (Math.abs(now_seconds - t) > MAX_AGE_SECONDS) return false;
const expected_hex = createHmac('sha256', SILL_SECRET)
.update(`${t}.${raw_body}`)
.digest('hex');
const a = Buffer.from(expected_hex, 'hex');
const b = Buffer.from(sig, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// Express example — capture the RAW body via `express.raw`.
import express from 'express';
const app = express();
app.post(
'/sill/skills',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const header = req.get('x-sill-signature') ?? '';
if (!verifySillSignature(header, raw)) {
res.status(401).send('');
return;
}
const call = JSON.parse(raw) as {
skill_id: string;
site_id: string;
arguments: Record<string, unknown>;
observed_at: string;
nonce: string;
};
// Route on call.skill_id and return the canonical response shape.
},
);
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
SILL_SECRET = os.environ["SILL_SKILL_SECRET"].encode("utf-8")
MAX_AGE_SECONDS = 5 * 60
def verify_sill_signature(header_value: str, raw_body: bytes) -> bool:
t = -1
sig = ""
for part in header_value.split(","):
if "=" not in part:
continue
k, v = part.split("=", 1)
if k == "t":
try:
t = int(v)
except ValueError:
return False
elif k == "v1":
sig = v
if t < 0 or not sig:
return False
if abs(int(time.time()) - t) > MAX_AGE_SECONDS:
return False
signed_payload = f"{t}.".encode("utf-8") + raw_body
expected = hmac.new(SILL_SECRET, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
app = Flask(__name__)
@app.post("/sill/skills")
def sill_skills():
raw = request.get_data()
header = request.headers.get("X-Sill-Signature", "")
if not verify_sill_signature(header, raw):
abort(401)
call = request.get_json()
# Route on call["skill_id"] and return the canonical response shape.
return {}
package sillfulfill
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var sillSecret = []byte(os.Getenv("SILL_SKILL_SECRET"))
const maxAgeSeconds int64 = 5 * 60
func VerifySillSignature(headerValue string, rawBody []byte) bool {
var t int64 = -1
var sig string
for _, part := range strings.Split(headerValue, ",") {
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
k := part[:eq]
v := part[eq+1:]
switch k {
case "t":
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return false
}
t = n
case "v1":
sig = v
}
}
if t < 0 || sig == "" {
return false
}
now := time.Now().Unix()
diff := now - t
if diff < 0 {
diff = -diff
}
if diff > maxAgeSeconds {
return false
}
mac := hmac.New(sha256.New, sillSecret)
mac.Write([]byte(strconv.FormatInt(t, 10)))
mac.Write([]byte{'.'})
mac.Write(rawBody)
expected := mac.Sum(nil)
got, err := hex.DecodeString(sig)
if err != nil {
return false
}
return hmac.Equal(expected, got)
}
func SillSkillsHandler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !VerifySillSignature(r.Header.Get("X-Sill-Signature"), raw) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Route on the parsed body's `skill_id`.
}

The body Sill POSTs to your endpoint is a small JSON envelope. It is the same shape for every skill; the per-skill argument shapes live inside arguments:

{
"skill_id": "order_status",
"site_id": "01EXAMPLE00000000000000000",
"arguments": {
"order_id": "#1001",
"customer_email": "[email protected]"
},
"observed_at": "2026-07-05T18:22:15.140Z",
"nonce": "01K1EXAMPLE0000000000000000"
}
  • skill_id — the canonical snake_case skill identifier.
  • site_id — your site’s ULID.
  • arguments — the per-skill request payload; each skill page documents its schema.
  • observed_at — ISO-8601 UTC of when Sill received the agent’s call.
  • nonce — a unique per-call identifier. Combine with t from the signature header to replay-guard.

Your successful response is a JSON body matching the canonical response schema for the skill.

This is version v1. Additive optional fields on requests or responses are backward-compatible with v1 clients — Sill’s response validator will not reject a body that omits an optional field, and merchant handlers should ignore optional request fields they do not use.

Removals or renames are a v2 change. When a v2 lands, both versions will serve in parallel for at least one release cycle.