SilvectorDocs
Silvector docsQuickstartUpdated 2026-11-07

Quickstart

Go

Get a decision, verify its receipt against the published keys, and watch a changed receipt fail. About ten minutes, most of it reading.

You need

Go 1.22 or later. Standard library only.

1. Create the files

go.mod
module example.com/silvector-quickstart

go 1.22
main.go
// Silvector quickstart for Go 1.22 or later. Standard library only.
// Run: go run .
package main

import (
	"bytes"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"slices"
	"strings"
	"time"
	"unicode/utf16"
)

type receipt struct {
	Body map[string]any `json:"body"`
	Sig  string         `json:"sig"`
}

type jwk struct {
	Kty string `json:"kty"`
	Crv string `json:"crv"`
	Kid string `json:"kid"`
	X   string `json:"x"`
}

// writeString escapes the way JavaScript's JSON.stringify does, which the signer uses.
// encoding/json differs: it escapes U+2028, U+2029 and HTML characters.
func writeString(buf *bytes.Buffer, s string) {
	buf.WriteByte('"')
	for _, r := range s {
		switch r {
		case '"':
			buf.WriteString(`\"`)
		case '\\':
			buf.WriteString(`\\`)
		case '\b':
			buf.WriteString(`\b`)
		case '\f':
			buf.WriteString(`\f`)
		case '\n':
			buf.WriteString(`\n`)
		case '\r':
			buf.WriteString(`\r`)
		case '\t':
			buf.WriteString(`\t`)
		default:
			if r < 0x20 {
				fmt.Fprintf(buf, `\u%04x`, r)
			} else {
				buf.WriteRune(r)
			}
		}
	}
	buf.WriteByte('"')
}

// compareUTF16 orders keys by UTF-16 code unit, as JavaScript sorts them. Go's string
// order is by UTF-8 byte, which differs for characters outside the Basic Multilingual Plane.
func compareUTF16(a, b string) int {
	return slices.Compare(utf16.Encode([]rune(a)), utf16.Encode([]rune(b)))
}

// canonical writes JSON with keys sorted, no whitespace and integers only:
// the exact bytes the receipt signature covers.
func canonical(buf *bytes.Buffer, value any) error {
	switch v := value.(type) {
	case nil:
		buf.WriteString("null")
	case bool:
		fmt.Fprintf(buf, "%t", v)
	case json.Number:
		if strings.ContainsAny(v.String(), ".eE") {
			return fmt.Errorf("receipts carry integers only, got %s", v)
		}
		if v.String() == "-0" {
			buf.WriteString("0")
		} else {
			buf.WriteString(v.String())
		}
	case string:
		writeString(buf, v)
	case []any:
		buf.WriteByte('[')
		for i, item := range v {
			if i > 0 {
				buf.WriteByte(',')
			}
			if err := canonical(buf, item); err != nil {
				return err
			}
		}
		buf.WriteByte(']')
	case map[string]any:
		keys := make([]string, 0, len(v))
		for k := range v {
			keys = append(keys, k)
		}
		slices.SortFunc(keys, compareUTF16)
		buf.WriteByte('{')
		for i, k := range keys {
			if i > 0 {
				buf.WriteByte(',')
			}
			if err := canonical(buf, k); err != nil {
				return err
			}
			buf.WriteByte(':')
			if err := canonical(buf, v[k]); err != nil {
				return err
			}
		}
		buf.WriteByte('}')
	default:
		return fmt.Errorf("unexpected JSON value %T", value)
	}
	return nil
}

func verifies(r receipt, key jwk) bool {
	pub, err := base64.RawURLEncoding.DecodeString(key.X)
	if err != nil || len(pub) != ed25519.PublicKeySize {
		return false
	}
	sig, err := base64.RawURLEncoding.DecodeString(r.Sig)
	if err != nil {
		return false
	}
	var msg bytes.Buffer
	if err := canonical(&msg, r.Body); err != nil {
		return false
	}
	return ed25519.Verify(pub, msg.Bytes(), sig)
}

func getJSON(client *http.Client, req *http.Request, out any) error {
	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("%s returned %d", req.URL.Path, res.StatusCode)
	}
	dec := json.NewDecoder(res.Body)
	dec.UseNumber()
	return dec.Decode(out)
}

func run() (bool, error) {
	api := os.Getenv("SILVECTOR_API")
	if api == "" {
		api = "https://api.silvector.com"
	}
	client := &http.Client{Timeout: 15 * time.Second}

	// 1. Ask for a decision. The response carries a signed receipt.
	checkReq, _ := http.NewRequest(http.MethodPost, api+"/v1/check", strings.NewReader(`{"method":"GET","path":"/pricing"}`))
	checkReq.Header.Set("content-type", "application/json")
	var decision struct {
		ReceiptID string  `json:"receiptId"`
		Receipt   receipt `json:"receipt"`
	}
	if err := getJSON(client, checkReq, &decision); err != nil {
		return false, err
	}
	r := decision.Receipt

	// 2. Fetch the published keys.
	jwksReq, _ := http.NewRequest(http.MethodGet, api+"/.well-known/jwks.json", nil)
	var jwks struct {
		Keys []jwk `json:"keys"`
	}
	if err := getJSON(client, jwksReq, &jwks); err != nil {
		return false, err
	}
	var keys []jwk
	for _, k := range jwks.Keys {
		if k.Kty == "OKP" && k.Crv == "Ed25519" {
			keys = append(keys, k)
		}
	}

	// 3. Verify with the one key whose kid equals the receipt's agent_kid. Other published
	// keys sign other things, so never fall back to them.
	var key *jwk
	for i := range keys {
		if keys[i].Kid == r.Body["agent_kid"] {
			key = &keys[i]
			break
		}
	}
	verified := key != nil && verifies(r, *key)

	// 4. Change one field and check that the signature no longer holds.
	tamperedBody := map[string]any{}
	for k, v := range r.Body {
		tamperedBody[k] = v
	}
	if r.Body["outcome"] == "allow" {
		tamperedBody["outcome"] = "deny"
	} else {
		tamperedBody["outcome"] = "allow"
	}
	tampered := receipt{Body: tamperedBody, Sig: r.Sig}
	tamperedVerifies := key != nil && verifies(tampered, *key)

	fmt.Printf("receipt: %s\n", decision.ReceiptID)
	fmt.Printf("outcome: %v (rule %v)\n", r.Body["outcome"], r.Body["rule_id"])
	if key != nil {
		fmt.Printf("key: %s\n", key.Kid)
	} else {
		fmt.Printf("key: none published for %v\n", r.Body["agent_kid"])
	}
	fmt.Printf("verified: %t\n", verified)
	fmt.Printf("tampered copy verifies: %t\n", tamperedVerifies)
	fmt.Println("evidence grade: rung 2 Operational, confidence none, uncalibrated (assigned by the docs, not a signed field)")
	return verified && !tamperedVerifies, nil
}

func main() {
	ok, err := run()
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	if !ok {
		os.Exit(1)
	}
}

2. Run it

Run
go run .

3. Read the output

REPLAY Recorded 2026-11-07 against api.silvector.com. Your receipt id and timestamp will differ.

Output
receipt: b8d4f2ceb697b3904d50459c649d6fc220418387d87332d04ab47cf2bd32fa98
outcome: allow (rule allow.default)
key: edge-receipt
verified: true
tampered copy verifies: false
evidence grade: rung 2 Operational, confidence none, uncalibrated (assigned by the docs, not a signed field)
  1. Decide. POST /v1/check ran the request shape through the public policy for tenant try and returned the outcome, the rule and a signed receipt.
  2. Fetch the keys. GET /.well-known/jwks.json returned the public Ed25519 keys.
  3. Verify. The script rebuilt the canonical JSON of the receipt body and checked the signature with the one key whose kid equals the receipt's agent_kid. It never tries the other published keys: they sign other things, such as sessions, so a receipt that verifies under one of them is not a valid receipt.
  4. Tamper. It flipped the outcome and checked again. The signature no longer holds, which is the point of a receipt.

4. The evidence grade of this receipt

A receipt is a rung 2 record: it shows which rule decided and that the record was not changed. It does not, by itself, say whether the sender was an AI agent.

Evidence grade Rung 2 · Operational
Covers
Which rule decided this request and that the record was not changed after signing. Not whether the sender was an AI agent.
Confidence
None stated. No calibrated confidence applies to this record.
Calibration
Uncalibrated
Known error rates
None measured. Silvector publishes no detection error rate yet.
Basis
  • Deterministic decide() rule allow.default
  • Ed25519 signature over the canonical receipt body, checked against the published JWKS
Bias risks
  • The decision is only as good as the facts it was given. A request with no signature claim is not_identified, which is not a finding that a person or an agent sent it.
Next step
To ask who was behind a series of requests, keep the receipts and escalate to rung 3 with at least two independent signal families. How escalation works
Assigned by
These docs, from the rung definitions. The grade is not a signed field of the receipt.

Notes for Go

  • Decode with UseNumber so integers keep their exact digits; the canonical form carries integers only.
  • encoding/json escapes strings and orders keys differently from the JavaScript signer (it escapes <, >, &, U+2028 and U+2029, and sorts by UTF-8 byte), so the quickstart writes strings and sorts keys itself.

Troubleshooting

  • 429 Too Many Requests. The public check endpoint allows 60 requests a minute per caller. Wait a minute.
  • verified: false. A value in the receipt body changed after signing, or your verifier builds the canonical form differently: keys sorted by UTF-16 code unit, no whitespace, integers only, strings escaped as JavaScript's JSON.stringify escapes them. Whitespace, key order and escape style in the JSON you received do not matter; the verifier rebuilds the canonical form from the parsed values.
  • key: none published for … The key set has no key with the receipt's agent_kid, so the receipt cannot be verified. pnpm --filter @ag47/api dev publishes test keys under thumbprint ids and shows this; run pnpm --filter @ag47/docs api instead.
  • Another API. Set SILVECTOR_API, for example to http://127.0.0.1:8787 after pnpm --filter @ag47/docs api in the repository. For the Workers quickstart, give wrangler dev another port with --port and pass the URL with --var SILVECTOR_API:http://127.0.0.1:8787.

Next

Source: apps/docs/quickstarts/go/. All quickstarts