agentkit

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

agentkit

Go Reference

The LunarWind agent contract: the event LunarWind delivers to an agent, the signature that proves it came from LunarWind, and a client for the endpoints an agent calls back on.

go get lunarwind.se/agentkit

This repository is generated. The source of truth is the agentkit/ directory of LunarWind's main repository, where the server that signs these deliveries lives beside it. Pull requests here cannot be merged — the publish is a force-push and would destroy them. File issues instead.

That arrangement is the point rather than an inconvenience: LunarWind signs every delivery with the same Sign your receiver verifies with, from the same file, so the two halves of the contract cannot drift apart. One implementation, published twice.

Zero dependencies

go.mod has no require block. The whole kit is the standard library, so depending on it adds exactly one module to your graph and nothing transitive. That is unusual for an SDK and it is deliberate — an agent should not inherit chi, pgx, NATS and templ to receive a webhook.

An agent in full

An agent is an HTTP server first and a client second. LunarWind pushes an event to it; it calls LunarWind back.

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"

	"lunarwind.se/agentkit"
)

func main() {
	secret := []byte(os.Getenv("LUNARWIND_WEBHOOK_SECRET"))
	client := agentkit.NewClient("https://lunarwind.se", os.Getenv("LUNARWIND_TOKEN"))

	http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
		// Verify BEFORE parsing. Everything above this line is untrusted.
		body, err := agentkit.Verify(secret, r, agentkit.DefaultTolerance)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		var ev agentkit.Event
		if err := json.Unmarshal(body, &ev); err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		// Stable across retries, so it is the right key for "have I already
		// answered this?".
		id := agentkit.DeliveryID(r)

		// Acknowledge fast, answer slowly. LunarWind waits ten seconds and
		// retries what it does not hear back on, so the work belongs off this
		// request.
		w.WriteHeader(http.StatusAccepted)
		go answer(client, id, ev)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}

func answer(c *agentkit.Client, deliveryID string, ev agentkit.Event) {
	ctx := context.Background()
	if err := c.Typing(ctx, ev.Thread.ID); err != nil {
		log.Printf("typing: %v", err)
	}
	if _, err := c.Reply(ctx, ev.Thread.ID, "Looking into it."); err != nil {
		log.Printf("reply to %s: %v", deliveryID, err)
	}
}

The signature

SIGNATURE.md writes the scheme out in full — HMAC-SHA256 over "<unix seconds>." + rawBody, hex, sha256= prefix, five-minute window, constant-time compare — for receivers written in languages this module does not cover.

testdata/signature-vectors.json publishes worked examples. If you are hand-writing a verifier, check it against those before you check it against yourself: a verifier whose own tests sign and verify with the same code passes even when the construction is wrong.

What an agent may do

Reply post into a thread
Edit replace a message it posted
Typing show the typing indicator while it works
Transcript read a thread it has access to
ReplyWithQuery / EditWithQuery attach a proposed SQL query to a message

An agent is a users row in LunarWind with kind = 'agent', so it reaches exactly the channels it has been added to — the same SQL that stops a person stops it. There is no second permission model.

The query proposal exists for a specific constraint: an agent that must not put personal data into a model can still answer questions about one person by proposing the SQL and one sentence explaining it. A human presses Run, the agent executes it, and the rows are shown in the thread without ever entering the model's context.

Versioning

v0. The contract is young and the shape may still move; pin a version. This is unrelated to LunarWind's own release numbering.

License

Apache-2.0. See LICENSE.

Documentation

Overview

Package agentkit is the LunarWind agent contract: the event LunarWind delivers, the signature that proves it came from LunarWind, and a client for the four endpoints an agent may call back on.

It is the reference implementation of that contract rather than a convenience wrapper around it, which is why it lives in LunarWind's own repository and is versioned with the contract it describes. Both sides use it: the server signs and delivers with the same code an agent verifies with, so the two cannot drift.

An agent's service is an HTTP server first and a client second. LunarWind pushes an event to it; it calls LunarWind back. See BUILDING-AN-AGENT.md.

Example

A whole agent: an HTTP server that receives events, and a client that answers them.

This is compiled by the test suite, so the code on the module's documentation page is code that builds.

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"

	"lunarwind.se/agentkit"
)

// A whole agent: an HTTP server that receives events, and a client that
// answers them.
//
// This is compiled by the test suite, so the code on the module's
// documentation page is code that builds.
func main() {
	secret := []byte(os.Getenv("LUNARWIND_WEBHOOK_SECRET"))
	client := agentkit.NewClient("https://lunarwind.se", os.Getenv("LUNARWIND_TOKEN"))

	http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
		// Verify BEFORE parsing. Everything above this line is untrusted.
		body, err := agentkit.Verify(secret, r, agentkit.DefaultTolerance)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		var ev agentkit.Event
		if err := json.Unmarshal(body, &ev); err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		// Stable across retries, so it is the right key for "have I already
		// answered this?".
		id := agentkit.DeliveryID(r)

		// Acknowledge fast, answer slowly. LunarWind waits ten seconds and
		// retries what it does not hear back on, so the work belongs off this
		// request.
		w.WriteHeader(http.StatusAccepted)
		go answer(client, id, ev)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}

func answer(c *agentkit.Client, deliveryID string, ev agentkit.Event) {
	ctx := context.Background()
	if err := c.Typing(ctx, ev.Thread.ID); err != nil {
		log.Printf("typing: %v", err)
	}
	if _, err := c.Reply(ctx, ev.Thread.ID, "Looking into it."); err != nil {
		log.Printf("reply to %s: %v", deliveryID, err)
	}
}

Index

Examples

Constants

View Source
const (
	// ReasonMentioned - the message names this agent.
	ReasonMentioned = "mentioned"
	// ReasonThreadFollowUp - a new message in a thread this agent has already
	// spoken in, without naming it.
	ReasonThreadFollowUp = "thread_follow_up"
	// ReasonChannelMessage - a message in a joined channel, naming nobody.
	ReasonChannelMessage = "channel_message"
)

Delivery reasons. An agent may reasonably answer a direct address and stay quiet on follow-up it was not asked about; a bot that replies to every message in a busy channel is one that gets removed from it.

View Source
const (
	KindHuman = "human"
	KindAgent = "agent"
)

Author kinds.

View Source
const (
	HeaderSignature = "X-LunarWind-Signature"
	HeaderTimestamp = "X-LunarWind-Timestamp"
	HeaderDelivery  = "X-LunarWind-Delivery"
)

The headers that carry the proof.

View Source
const DefaultTolerance = 5 * time.Minute

DefaultTolerance bounds how old a delivery may be and still be accepted.

Without a bound, a signature captured once is valid forever: anybody who obtains one delivery can replay it at will. Five minutes is enough slack for clock skew between two machines that are not deliberately misconfigured.

View Source
const EventMessageCreated = "message.created"

EventMessageCreated is the only event LunarWind sends today. It is a field rather than an assumption so that a receiver written now keeps working when there is a second one: switch on it, and ignore what you do not recognise.

View Source
const EventQueryRun = "query.run"

EventQueryRun is sent when somebody presses Run on a proposal.

Unlike message.created, this one is a request rather than an announcement: the agent answers 200 with a RunResult in the body, and LunarWind waits for it because a person is watching a spinner.

View Source
const MaxBodyBytes = 1 << 20 // 1 MiB

MaxBodyBytes caps what Verify will read. A receiver must never let an unauthenticated caller decide how much memory to allocate.

View Source
const MaxRunRows = 200

MaxRunRows is the most an agent should return. A person is reading this in a chat thread, not exporting a report, and an unbounded result set is a way to put a database dump on a screen.

Variables

View Source
var (
	ErrNoSignature   = errors.New("agentkit: missing signature header")
	ErrNoTimestamp   = errors.New("agentkit: missing or malformed timestamp header")
	ErrStale         = errors.New("agentkit: timestamp outside tolerance")
	ErrBadSignature  = errors.New("agentkit: signature does not match")
	ErrBodyTooLarge  = errors.New("agentkit: body exceeds the maximum size")
	ErrMalformedBody = errors.New("agentkit: body is not a valid event")
)

Verification failures, distinguishable so a receiver can log which one it was. All of them mean "reject", and none of the detail should be returned to the caller - telling an attacker which part of their guess was wrong is how they find the part that was right.

Functions

func DeliveryID

func DeliveryID(r *http.Request) string

DeliveryID returns the id LunarWind assigned this delivery.

It is stable across retries, which is what makes it the right key for "have I already answered this?". A retry means LunarWind did not hear the first acknowledgement, not that anybody asked twice.

func Sign

func Sign(secret []byte, timestamp time.Time, body []byte) string

Sign returns the value for HeaderSignature over one delivery.

The signed string is "<unix seconds>.<raw body>", so the timestamp is covered too. Signing the body alone would let an attacker replay a captured body under a fresh timestamp of their choosing, which is exactly the attack the timestamp exists to stop.

func Verify

func Verify(secret []byte, r *http.Request, tolerance time.Duration) ([]byte, error)

Verify reads and authenticates a delivery, returning the raw body.

It exists because three mistakes are easy to make independently and each one silently removes the protection:

  1. Verifying a re-encoded body. Decoding to a struct and marshalling again to check the signature fails intermittently and inexplicably, because no JSON encoder reproduces the sender's bytes exactly - key order, escaping and whitespace are all free choices. Verify hashes what arrived.

  2. Not bounding the timestamp. See DefaultTolerance.

  3. Comparing with ==, which returns on the first differing byte and so leaks the expected signature one byte at a time to a caller who measures how long the answer took. hmac.Equal takes the same time either way.

The body is returned rather than decoded so that a caller may keep the exact bytes - for a delivery log, or to verify again later.

Types

type Author

type Author struct {
	ID       string `json:"id"`
	Username string `json:"username"`
	Kind     string `json:"kind"`
}

Author is who wrote it.

Kind is always KindHuman: LunarWind never delivers a message written by an agent, because two agents in one channel answering each other is an infinite loop that bills real money. The field is here so a receiver can assert that rather than assume it.

type Client

type Client struct {
	// BaseURL is LunarWind's origin, e.g. https://lunarwind.se. Trailing
	// slashes are tolerated.
	BaseURL string
	// Token is the bearer token issued when the agent was registered.
	Token string
	// HTTP is optional; without it a client with a sane timeout is used.
	HTTP *http.Client
}

Client calls the four endpoints an agent may use. Every call is authorised by the agent's bearer token, which resolves to its own user - so an agent can only reach channels it has joined, enforced by the same SQL that governs a person.

func NewClient

func NewClient(baseURL, token string) *Client

NewClient returns a Client with a timeout that will not outlive a request.

func (*Client) Edit

func (c *Client) Edit(ctx context.Context, messageID, content string) (PostedMessage, error)

Edit replaces the content of a message this agent wrote. An agent may only edit its own messages; LunarWind rejects anything else.

func (*Client) EditWithQuery

func (c *Client) EditWithQuery(ctx context.Context, messageID, content string, q *QueryProposal) (PostedMessage, error)

EditWithQuery rewrites a message and attaches a proposal to it, which is the usual shape: the placeholder posted at the start becomes either the answer or the offer to find it.

func (*Client) Reply

func (c *Client) Reply(ctx context.Context, threadID, content string) (PostedMessage, error)

Reply posts a new message into a thread.

Post a placeholder with this the moment work starts, then Edit it when the answer is ready: LunarWind pushes the edit to everyone with the thread open, so a minute-long answer shows life immediately instead of looking like silence. Silence is indistinguishable from being ignored.

func (*Client) ReplyWithQuery

func (c *Client) ReplyWithQuery(ctx context.Context, threadID, content string, q *QueryProposal) (PostedMessage, error)

ReplyWithQuery posts a message that offers to look something up.

For a question an agent may not answer itself - one about an identified person, where the rows must not reach the model - the content says why and the proposal says how. LunarWind renders it with a Run button; a person decides; the rows come back to this process and must not go into the loop.

A nil proposal is an ordinary reply.

func (*Client) Transcript

func (c *Client) Transcript(ctx context.Context, threadID string) (Transcript, error)

Transcript reads a thread the agent can see.

func (*Client) Typing

func (c *Client) Typing(ctx context.Context, threadID string) error

Typing shows the agent as thinking. It writes nothing and may be called repeatedly; failing to show an indicator is never worth failing a reply over, so callers should log and continue.

type Error

type Error struct {
	StatusCode int
	Body       string
}

Error is a non-2xx answer from LunarWind, with the body kept for the log.

func (*Error) Error

func (e *Error) Error() string

type Event

type Event struct {
	Event       string    `json:"event"`
	DeliveryID  string    `json:"delivery_id"`
	DeliveredAt time.Time `json:"delivered_at"`

	Workspace Ref     `json:"workspace"`
	Channel   Ref     `json:"channel"`
	Thread    Thread  `json:"thread"`
	Message   Message `json:"message"`
	Author    Author  `json:"author"`

	// Reason is why this message reached you: one of the Reason constants.
	Reason string `json:"reason"`
	// Mentioned is true when the message names this agent. Kept alongside
	// Reason because "was I addressed" is the question most receivers ask, and
	// a boolean is harder to get wrong than a string comparison.
	Mentioned bool `json:"mentioned"`

	Links Links `json:"links"`
}

Event is the webhook body. Every id is a short base62 id, safe in a URL.

type Links struct {
	Reply      string `json:"reply"`
	Typing     string `json:"typing"`
	Transcript string `json:"transcript"`
}

Links are the endpoints for this particular delivery, absolute and ready to call. Prefer them over building URLs from ids: a path that changes in a later version of LunarWind changes here first, and an agent that follows them keeps working.

type Message

type Message struct {
	ID        string    `json:"id"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"created_at"`
}

Message is what somebody wrote.

type PostedMessage

type PostedMessage struct {
	ID        string     `json:"id"`
	ThreadID  string     `json:"thread_id"`
	Content   string     `json:"content"`
	CreatedAt time.Time  `json:"created_at"`
	EditedAt  *time.Time `json:"edited_at,omitempty"`
}

PostedMessage is what the reply and edit endpoints return.

type QueryProposal

type QueryProposal struct {
	// SQL is shown to the person before they run it. An unreadable query is
	// one nobody can meaningfully refuse, so write it to be read.
	SQL string `json:"sql"`
	// Explanation is one plain sentence: what this will show. Written for
	// somebody who does not read SQL, because that is who decides.
	Explanation string `json:"explanation"`
}

QueryProposal is what an agent posts in place of an answer.

type Ref

type Ref struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

Ref is an addressable thing with a human-readable name.

type RunRequest

type RunRequest struct {
	Event       string        `json:"event"`
	DeliveryID  string        `json:"delivery_id"`
	DeliveredAt time.Time     `json:"delivered_at"`
	Workspace   Ref           `json:"workspace"`
	Thread      Thread        `json:"thread"`
	MessageID   string        `json:"message_id"`
	Query       QueryProposal `json:"query"`
	// RequestedBy is the person who pressed the button, already checked by
	// LunarWind to be a member of the channel the proposal lives in. An agent
	// may apply its own rules on top; it may not assume there were none.
	RequestedBy Author `json:"requested_by"`
}

RunRequest is the event LunarWind sends when Run is pressed. It is signed exactly like a delivery, and Verify accepts it the same way.

type RunResult

type RunResult struct {
	Columns []string   `json:"columns"`
	Rows    [][]string `json:"rows"`
	// Truncated says the result was cut at MaxRunRows, so the reader knows
	// they are looking at a prefix rather than an answer.
	Truncated bool `json:"truncated"`
	// Error is the agent's own message when the query could not run. It is
	// shown verbatim: the person who pressed Run is the one who needs it.
	Error string `json:"error,omitempty"`
}

RunResult is the agent's answer, in the response body of the run request.

Rows are strings because they are going straight into a table for a person to read: formatting a value is the agent's job, where the column's meaning is known, rather than LunarWind's, where it is not.

type Thread

type Thread struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

Thread is the conversation the message belongs to.

type Transcript

type Transcript struct {
	Thread   Thread            `json:"thread"`
	Channel  Ref               `json:"channel"`
	Messages []TranscriptEntry `json:"messages"`
}

Transcript is the thread as the agent may read it.

type TranscriptEntry

type TranscriptEntry struct {
	ID        string     `json:"id"`
	Content   string     `json:"content"`
	CreatedAt time.Time  `json:"created_at"`
	EditedAt  *time.Time `json:"edited_at,omitempty"`
	Author    Author     `json:"author"`
}

TranscriptEntry is one message in a thread, with enough about its author to tell people apart and to tell an agent's own replies from everyone else's.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL