feat(tools): add Resend calendar invite tool

Adds a native Go tool `send_calendar_invite` that generates RFC 5545
iCalendar (.ics) files and sends them via the Resend email API.

- ResendConfig struct with APIKey/FromAddress, overridable via
  PICOCLAW_TOOLS_RESEND_API_KEY / PICOCLAW_TOOLS_RESEND_FROM_ADDRESS
- Tool registered in shared tools when API key is configured
- ICS generation in pure Go (no new dependencies)
- Skill doc in workspace/skills/resend-calendar/SKILL.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sushi30 2026-03-01 22:06:26 +01:00
parent d6e88da8ba
commit 4258f884e1
5 changed files with 239 additions and 0 deletions

View file

@ -241,6 +241,10 @@
"enable_deny_patterns": false, "enable_deny_patterns": false,
"custom_deny_patterns": [] "custom_deny_patterns": []
}, },
"resend": {
"api_key": "",
"from_address": "Agent <agent@yourdomain.com>"
},
"skills": { "skills": {
"registries": { "registries": {
"clawhub": { "clawhub": {

View file

@ -155,6 +155,11 @@ func registerSharedTools(
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
// Calendar invite tool (requires Resend API key)
if cfg.Tools.Resend.APIKey != "" {
agent.Tools.Register(tools.NewSendCalendarInviteTool(&cfg.Tools.Resend))
}
// Spawn tool with allowlist checker // Spawn tool with allowlist checker
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)

View file

@ -541,12 +541,18 @@ type MediaCleanupConfig struct {
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
} }
type ResendConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_RESEND_API_KEY"`
FromAddress string `json:"from_address" env:"PICOCLAW_TOOLS_RESEND_FROM_ADDRESS"`
}
type ToolsConfig struct { type ToolsConfig struct {
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
Resend ResendConfig `json:"resend"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {

188
pkg/tools/email_calendar.go Normal file
View file

@ -0,0 +1,188 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// SendCalendarInviteTool sends an iCalendar (.ics) meeting invite via Resend.
type SendCalendarInviteTool struct {
cfg *config.ResendConfig
}
func NewSendCalendarInviteTool(cfg *config.ResendConfig) *SendCalendarInviteTool {
return &SendCalendarInviteTool{cfg: cfg}
}
func (t *SendCalendarInviteTool) Name() string {
return "send_calendar_invite"
}
func (t *SendCalendarInviteTool) Description() string {
return "Send a calendar invitation (.ics file) via email to a recipient. Use this when the user wants to schedule a meeting, set up an appointment, or send a calendar event invite."
}
func (t *SendCalendarInviteTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"to": map[string]any{
"type": "string",
"description": "Recipient email address",
},
"summary": map[string]any{
"type": "string",
"description": "Event title or subject",
},
"start": map[string]any{
"type": "string",
"description": "Event start time in ISO 8601 format (e.g. 2026-03-10T14:00:00 or 2026-03-10T14:00:00Z)",
},
"end": map[string]any{
"type": "string",
"description": "Event end time in ISO 8601 format (e.g. 2026-03-10T15:00:00 or 2026-03-10T15:00:00Z)",
},
"description": map[string]any{
"type": "string",
"description": "Optional meeting notes or agenda",
},
},
"required": []string{"to", "summary", "start", "end"},
}
}
func (t *SendCalendarInviteTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
to, _ := args["to"].(string)
summary, _ := args["summary"].(string)
start, _ := args["start"].(string)
end, _ := args["end"].(string)
description, _ := args["description"].(string)
if to == "" || summary == "" || start == "" || end == "" {
return ErrorResult("to, summary, start, and end are required")
}
ics, err := generateICS(summary, start, end, description)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to generate calendar invite: %v", err))
}
if err := sendViaResend(ctx, t.cfg, to, summary, ics); err != nil {
return ErrorResult(fmt.Sprintf("failed to send calendar invite: %v", err))
}
return &ToolResult{
ForLLM: fmt.Sprintf("Calendar invite for '%s' sent successfully to %s", summary, to),
ForUser: fmt.Sprintf("Calendar invite sent to %s", to),
}
}
// generateICS creates an RFC 5545-compliant iCalendar payload.
func generateICS(summary, startStr, endStr, description string) ([]byte, error) {
startTime, err := parseISOTime(startStr)
if err != nil {
return nil, fmt.Errorf("invalid start time %q: %w", startStr, err)
}
endTime, err := parseISOTime(endStr)
if err != nil {
return nil, fmt.Errorf("invalid end time %q: %w", endStr, err)
}
uid := fmt.Sprintf("%d@picoclaw.ai", time.Now().UnixNano())
now := time.Now().UTC().Format("20060102T150405Z")
dtstart := startTime.UTC().Format("20060102T150405Z")
dtend := endTime.UTC().Format("20060102T150405Z")
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("PRODID:-//PicoClaw AI//picoclaw.ai//\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("METHOD:REQUEST\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
fmt.Fprintf(&b, "UID:%s\r\n", uid)
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", now)
fmt.Fprintf(&b, "DTSTART:%s\r\n", dtstart)
fmt.Fprintf(&b, "DTEND:%s\r\n", dtend)
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeICSText(summary))
if description != "" {
fmt.Fprintf(&b, "DESCRIPTION:%s\r\n", escapeICSText(description))
}
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return []byte(b.String()), nil
}
func parseISOTime(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognised time format; use ISO 8601 (e.g. 2026-03-10T14:00:00)")
}
func escapeICSText(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
return s
}
type resendEmailRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
Attachments []resendAttachment `json:"attachments"`
}
type resendAttachment struct {
Filename string `json:"filename"`
Content []byte `json:"content"` // marshalled as base64 by encoding/json
}
func sendViaResend(ctx context.Context, cfg *config.ResendConfig, to, subject string, ics []byte) error {
payload := resendEmailRequest{
From: cfg.FromAddress,
To: []string{to},
Subject: subject,
HTML: fmt.Sprintf("<p>You have been invited to: <strong>%s</strong></p>", subject),
Attachments: []resendAttachment{
{Filename: "invite.ics", Content: ics},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("resend API error %d: %s", resp.StatusCode, strings.TrimSpace(string(errBody)))
}
return nil
}

View file

@ -0,0 +1,36 @@
---
name: resend-calendar
description: "Send calendar invitations (.ics) via email. Trigger when a user wants to schedule a meeting, book a time slot, or send a calendar event invite to someone."
metadata: {"nanobot":{"emoji":"📅"}}
---
# Send Calendar Invites
Use the `send_calendar_invite` tool to email an iCalendar (.ics) invitation. The invite appears as a native calendar event in Gmail, Outlook, and Apple Calendar.
## When to use
- "Schedule a meeting with alice@example.com"
- "Send a calendar invite for our 3pm call"
- "Book a 1-hour slot with bob@company.com tomorrow at 10am"
## Parameters
| Parameter | Required | Description |
|---------------|----------|------------------------------------------------------|
| `to` | yes | Recipient email address |
| `summary` | yes | Event title shown in the calendar |
| `start` | yes | Start time in ISO 8601 (e.g. `2026-03-10T14:00:00`) |
| `end` | yes | End time in ISO 8601 (e.g. `2026-03-10T15:00:00`) |
| `description` | no | Meeting notes or agenda |
## Example
User: "Invite charlie@example.com to a sync tomorrow at 2pm for 30 minutes"
Call `send_calendar_invite` with:
- `to`: `charlie@example.com`
- `summary`: `Sync`
- `start`: `2026-03-02T14:00:00`
- `end`: `2026-03-02T14:30:00`
- `description`: `Quick sync call`