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)
}
}
Output:
Index ¶
- Constants
- Variables
- func DeliveryID(r *http.Request) string
- func Sign(secret []byte, timestamp time.Time, body []byte) string
- func Verify(secret []byte, r *http.Request, tolerance time.Duration) ([]byte, error)
- type Author
- type Client
- func (c *Client) Edit(ctx context.Context, messageID, content string) (PostedMessage, error)
- func (c *Client) EditWithQuery(ctx context.Context, messageID, content string, q *QueryProposal) (PostedMessage, error)
- func (c *Client) Reply(ctx context.Context, threadID, content string) (PostedMessage, error)
- func (c *Client) ReplyWithQuery(ctx context.Context, threadID, content string, q *QueryProposal) (PostedMessage, error)
- func (c *Client) Transcript(ctx context.Context, threadID string) (Transcript, error)
- func (c *Client) Typing(ctx context.Context, threadID string) error
- type Error
- type Event
- type Links
- type Message
- type PostedMessage
- type QueryProposal
- type Ref
- type RunRequest
- type RunResult
- type Thread
- type Transcript
- type TranscriptEntry
Examples ¶
Constants ¶
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.
const ( KindHuman = "human" KindAgent = "agent" )
Author kinds.
const ( HeaderSignature = "X-LunarWind-Signature" HeaderTimestamp = "X-LunarWind-Timestamp" HeaderDelivery = "X-LunarWind-Delivery" )
The headers that carry the proof.
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.
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.
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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:
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.
Not bounding the timestamp. See DefaultTolerance.
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 (*Client) Edit ¶
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 ¶
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 ¶
Transcript reads a thread the agent can see.
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 ¶
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 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 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.