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:
parent
d6e88da8ba
commit
4258f884e1
5 changed files with 239 additions and 0 deletions
|
|
@ -241,6 +241,10 @@
|
|||
"enable_deny_patterns": false,
|
||||
"custom_deny_patterns": []
|
||||
},
|
||||
"resend": {
|
||||
"api_key": "",
|
||||
"from_address": "Agent <agent@yourdomain.com>"
|
||||
},
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,11 @@ func registerSharedTools(
|
|||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||
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
|
||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||
|
|
|
|||
|
|
@ -541,12 +541,18 @@ type MediaCleanupConfig struct {
|
|||
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 {
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Cron CronToolsConfig `json:"cron"`
|
||||
Exec ExecConfig `json:"exec"`
|
||||
Skills SkillsToolsConfig `json:"skills"`
|
||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||
Resend ResendConfig `json:"resend"`
|
||||
}
|
||||
|
||||
type SkillsToolsConfig struct {
|
||||
|
|
|
|||
188
pkg/tools/email_calendar.go
Normal file
188
pkg/tools/email_calendar.go
Normal 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
|
||||
}
|
||||
36
workspace/skills/resend-calendar/SKILL.md
Normal file
36
workspace/skills/resend-calendar/SKILL.md
Normal 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`
|
||||
Loading…
Add table
Reference in a new issue