diff --git a/AGENTS.md b/AGENTS.md index 5381315..8d48bb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ Common variables (subset; see command READMEs for complete lists): - Wrap the published output schema with `helpers.WithOptionalFields(...)` at the `OutputSchema:` line (not in the `init()` block β€” keep `get_*` schemas strict). This clears every nested `required` array so sparse responses returned when `verbose=false` still validate. `StructuredContent` is always populated in both modes. - OpenAI strict-mode caveat: because `verbose=false` returns a sparse subset of fields, `list_*` output schemas cannot satisfy OpenAI's strict structured-output mode (which requires every property to be `required` and forbids `additionalProperties`). Clients targeting `list_*` tools must run with strict mode disabled; `get_*` tools remain strict-compatible. - Run `go test ./internal/twprojects` until green. +- `docs/tool-reference.md` is generated by `cmd/docs-gen` and guarded by `go test`; ordinary tool add/remove/rename is caught automatically (regenerate with `go run ./cmd/docs-gen`), but two changes need a manual edit to `cmd/docs-gen/main.go`: flipping `allowDelete` to `true` on a shipped server, or adding a new product package (register it in `products()`). ## Security considerations for agents - Never print or commit bearer tokens. Use env vars only. Redact tokens in logs. diff --git a/README.md b/README.md index 8843371..f29e32c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ through AI agents. - **Production Ready**: Comprehensive logging, monitoring, and observability - **Read-Only Mode**: Optional restriction to read-only operations for safety +See the auto-generated **[Tool Reference](docs/tool-reference.md)** for every +create/read/update operation exposed across Projects, Desk, Spaces, and Chat. + ## πŸš€ Available Servers This project provides three different ways to interact with the Teamwork.com MCP diff --git a/cmd/docs-gen/main.go b/cmd/docs-gen/main.go new file mode 100644 index 0000000..065b84c --- /dev/null +++ b/cmd/docs-gen/main.go @@ -0,0 +1,293 @@ +// Command docs-gen generates a Markdown reference of the Teamwork MCP tool +// surface (the CRUD matrix) directly from the registered toolsets. +// +// It builds each product's default toolset group with writes and deletes +// enabled so the full surface is visible, then reflects over each tool's static +// metadata (name, read-only hint). No API token or live server is required β€” +// the engine / HTTP client are only used inside handler closures, never at +// registration time, so nil dependencies are safe here. +// +// Usage: +// +// go run ./cmd/docs-gen # write docs/tool-reference.md +// go run ./cmd/docs-gen -o - # write to stdout +// go run ./cmd/docs-gen -check # verify docs/tool-reference.md is current +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/mcp/internal/twchat" + "github.com/teamwork/mcp/internal/twdesk" + "github.com/teamwork/mcp/internal/twprojects" + "github.com/teamwork/mcp/internal/twspaces" +) + +// product pairs a human-readable label with its registered toolset group. +type product struct { + label string + group *toolsets.ToolsetGroup +} + +// products lists every Teamwork product surface in display order. Dependencies +// are nil: only static tool metadata is read, never the engine/HTTP client. +// +// The groups are built to mirror what the shipped servers (cmd/mcp-http, +// cmd/mcp-stdio) actually expose: writes enabled, deletes DISABLED +// (allowDelete=false). Delete tools exist in the codebase but no shipped server +// turns them on, so documenting them as available would be misleading β€” see the +// header note. +func products() []product { + return []product{ + {"Projects", twprojects.DefaultToolsetGroup(false, false, nil)}, + {"Desk", twdesk.DefaultToolsetGroup(false, nil)}, + {"Spaces", twspaces.DefaultToolsetGroup(false, false, nil)}, + {"Chat", twchat.DefaultToolsetGroup(false, nil)}, + } +} + +// verbColumn maps a leading action verb to its matrix column. Verbs not listed +// here are treated as "other actions" and listed separately. "delete" is +// deliberately absent: shipped servers never expose delete tools (see +// products()), and any that do appear should surface visibly under "Other +// actions" rather than in a column, never silently dropped. +var verbColumn = map[string]string{ + "create": "Create", + "get": "Get", + "list": "List", + "update": "Update", +} + +// matrixColumns is the fixed left-to-right order of matrix columns. +var matrixColumns = []string{"Create", "Get", "List", "Update"} + +// forceOther holds tool slugs (without the product prefix) whose verb-based +// classification is misleading, so they are listed under "Other actions" +// instead of forming a spurious resource row. +var forceOther = map[string]bool{ + "get_or_create_dm": true, +} + +// displayOverrides maps resource/toolset slugs whose default title-casing reads +// poorly to a hand-tuned display name. +var displayOverrides = map[string]string{ + "jobrole": "Job Role", + "user_me": "Current User (me)", + "current_user": "Current User", +} + +// resourceInfo tracks, for a single resource within a toolset, which CRUD +// columns are present. +type resourceInfo struct { + name string // display name, e.g. "Task" + order int // first-seen index for stable ordering + columns map[string]bool // column name -> present +} + +func main() { + out := flag.String("o", "docs/tool-reference.md", "output file, or - for stdout") + check := flag.Bool("check", false, + "verify the committed doc matches freshly generated output; exit non-zero if stale") + flag.Parse() + + content := generate() + + if *check { + path := *out + if path == "-" { + path = "docs/tool-reference.md" + } + committed, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading %s: %v\n", path, err) + os.Exit(1) + } + if !bytes.Equal(committed, []byte(content)) { + fmt.Fprintf(os.Stderr, + "%s is stale β€” run `go run ./cmd/docs-gen` to regenerate it\n", path) + os.Exit(1) + } + return + } + + if *out == "-" { + fmt.Print(content) + return + } + if err := os.WriteFile(*out, []byte(content), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "error writing %s: %v\n", *out, err) + os.Exit(1) + } + fmt.Printf("wrote %s\n", *out) +} + +// generate renders the full tool-reference Markdown document from the +// registered toolsets and returns it. It is the single source of truth for the +// document body, shared by main() (which writes it to disk) and the tests +// (which assert the committed file matches this output). +func generate() string { + var b strings.Builder + writeHeader(&b) + + for _, p := range products() { + fmt.Fprintf(&b, "\n## %s\n", p.label) + for _, method := range sortedMethods(p.group) { + ts, err := p.group.GetToolset(method) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: toolset %q not found: %v\n", method, err) + continue + } + writeToolset(&b, ts) + } + } + + return b.String() +} + +func writeHeader(b *strings.Builder) { + b.WriteString("# Teamwork MCP β€” Tool Reference\n\n") + b.WriteString("Auto-generated from the registered toolsets by `cmd/docs-gen`. ") + b.WriteString("Do not edit by hand β€” run `go run ./cmd/docs-gen` to regenerate.\n\n") + b.WriteString("This reflects the tools a client actually receives from the shipped servers ") + b.WriteString("(`cmd/mcp-http`, `cmd/mcp-stdio`) with writes enabled. **Delete operations are ") + b.WriteString("intentionally omitted**: they exist in the codebase but are gated behind an ") + b.WriteString("`allowDelete` flag that no shipped server enables, so no client can invoke them. ") + b.WriteString("Running a server with `-read-only` removes the write tools, leaving the Get/List ") + b.WriteString("operations plus any read-only entries under \"Other actions\" (e.g. `search`, ") + b.WriteString("`summarize_timelogs`, `users_workload`).\n") +} + +// sortedMethods returns a group's toolset methods in alphabetical order for +// deterministic output. +func sortedMethods(g *toolsets.ToolsetGroup) []toolsets.Method { + methods := make([]toolsets.Method, 0, len(g.Toolsets)) + for m := range g.Toolsets { + methods = append(methods, m) + } + sort.Slice(methods, func(i, j int) bool { return methods[i] < methods[j] }) + return methods +} + +func writeToolset(b *strings.Builder, ts *toolsets.Toolset) { + fmt.Fprintf(b, "\n### %s β€” `%s`\n\n", toolsetTitle(ts.Method), ts.Method) + if ts.Description != "" { + fmt.Fprintf(b, "%s\n\n", ts.Description) + } + + resources := map[string]*resourceInfo{} + var resourceKeys []string + var others []string + + for _, tw := range ts.GetAvailableTools() { + slug := stripPrefix(tw.Tool.Name) + verb, rest, _ := strings.Cut(slug, "_") + col, isCRUD := verbColumn[verb] + if !isCRUD || rest == "" || forceOther[slug] { + others = append(others, slug) + continue + } + key := singular(rest) + ri, ok := resources[key] + if !ok { + ri = &resourceInfo{name: displayName(key), order: len(resourceKeys), columns: map[string]bool{}} + resources[key] = ri + resourceKeys = append(resourceKeys, key) + } + ri.columns[col] = true + } + + // Matrix table. + b.WriteString("| Resource | " + strings.Join(matrixColumns, " | ") + " |\n") + b.WriteString("|" + strings.Repeat("---|", len(matrixColumns)+1) + "\n") + sort.SliceStable(resourceKeys, func(i, j int) bool { + return resources[resourceKeys[i]].order < resources[resourceKeys[j]].order + }) + for _, key := range resourceKeys { + ri := resources[key] + cells := make([]string, 0, len(matrixColumns)) + for _, col := range matrixColumns { + if ri.columns[col] { + cells = append(cells, "βœ“") + } else { + cells = append(cells, "β€”") + } + } + fmt.Fprintf(b, "| %s | %s |\n", ri.name, strings.Join(cells, " | ")) + } + + // Other (non-CRUD) actions. + if len(others) > 0 { + sort.Strings(others) + labels := make([]string, 0, len(others)) + for _, o := range others { + labels = append(labels, "`"+o+"`") + } + fmt.Fprintf(b, "\n**Other actions:** %s\n", strings.Join(labels, ", ")) + } +} + +// stripPrefix removes the "tw-" namespace prefix from a tool or method +// name, returning the action/toolset slug. +func stripPrefix(name string) string { + if i := strings.IndexByte(name, '-'); i >= 0 { + return name[i+1:] + } + return name +} + +func toolsetTitle(m toolsets.Method) string { + return displayName(stripPrefix(string(m))) +} + +// displayName converts a snake_case slug into a Title Case display name. +func displayName(slug string) string { + if override, ok := displayOverrides[slug]; ok { + return override + } + parts := strings.Split(slug, "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") +} + +// singular converts a (possibly plural) resource slug to a canonical singular +// key so that plural (list_*) and singular (get_*) tools collapse to one row β€” +// e.g. tasksβ†’task, statusesβ†’status, inboxesβ†’inbox, categoriesβ†’category. It also +// leaves already-singular forms untouched (status, address) so the two spellings +// map to the same key. +func singular(slug string) string { + switch { + case strings.HasSuffix(slug, "ies") && len(slug) > 4: + return slug[:len(slug)-3] + "y" + case strings.HasSuffix(slug, "us"), strings.HasSuffix(slug, "ss"): + return slug // status, campus, address β€” already singular + case strings.HasSuffix(slug, "es") && len(slug) > 3: + stem := slug[:len(slug)-2] + // -es plural of a sibilant stem (statusβ†’statuses, inboxβ†’inboxes, + // searchβ†’searches) drops "es"; otherwise it's a plain -s (typeβ†’types). + switch { + case strings.HasSuffix(stem, "s"), + strings.HasSuffix(stem, "x"), + strings.HasSuffix(stem, "z"), + strings.HasSuffix(stem, "ch"), + strings.HasSuffix(stem, "sh"): + return stem + default: + return slug[:len(slug)-1] + } + case strings.HasSuffix(slug, "s"): + return slug[:len(slug)-1] + default: + return slug + } +} diff --git a/cmd/docs-gen/main_test.go b/cmd/docs-gen/main_test.go new file mode 100644 index 0000000..0c0f433 --- /dev/null +++ b/cmd/docs-gen/main_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/mcp/internal/twprojects" +) + +// docPath is the committed reference doc, relative to this package's directory +// (the test working directory). +const docPath = "../../docs/tool-reference.md" + +// TestGeneratedDocMatchesCommitted is the GOLDEN guard: the committed +// docs/tool-reference.md must be byte-identical to freshly generated output. +// This catches the common case of a tool being added, removed, or renamed +// without regenerating the doc. +func TestGeneratedDocMatchesCommitted(t *testing.T) { + committed, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("reading committed doc %s: %v", filepath.Clean(docPath), err) + } + if got := generate(); got != string(committed) { + t.Errorf("docs/tool-reference.md is stale β€” run `go run ./cmd/docs-gen` to regenerate it") + } +} + +// placement describes where a single tool lands in the rendered doc: either a +// matrix cell (resource + CRUD column) or an "Other actions" entry. +type placement struct { + product string + method toolsets.Method + // exactly one of {resource,column} or other is set. + resource string // canonical singular resource key, matrix placements only + column string // Create/Get/List/Update, matrix placements only + other string // slug, "Other actions" placements only +} + +// classify mirrors writeToolset's logic to determine the placement of a single +// tool slug. It must stay in lockstep with writeToolset; the GOLDEN test guards +// against divergence because both consume the same helper functions and data. +func classify(product string, method toolsets.Method, toolName string) placement { + slug := stripPrefix(toolName) + verb, rest, _ := strings.Cut(slug, "_") + col, isCRUD := verbColumn[verb] + if !isCRUD || rest == "" || forceOther[slug] { + return placement{product: product, method: method, other: slug} + } + return placement{product: product, method: method, resource: singular(rest), column: col} +} + +func (p placement) isOther() bool { return p.other != "" } + +// TestBijection is the BIJECTION guard: every tool exposed by every doc-gen +// group must land in exactly one place in the doc (one matrix cell tick or one +// "Other actions" entry), and every rendered tick/entry must trace back to a +// real tool. It fails if a tool is silently dropped (two tools colliding into +// the same matrix cell) or duplicated. +func TestBijection(t *testing.T) { + generated := generate() + + // Count what the renderer actually emitted, independently of classify. + renderedTicks := strings.Count(generated, "βœ“") + + var matrixTools, otherTools int + // cellOwner detects collisions: two distinct tools mapping to the same + // matrix cell would render as a single tick, silently dropping one tool. + cellOwner := map[placement]string{} + otherOwner := map[placement]string{} + + for _, p := range products() { + for method, ts := range p.group.Toolsets { + for _, tw := range ts.GetAvailableTools() { + name := tw.Tool.Name + pl := classify(p.label, method, name) + if pl.isOther() { + if prev, ok := otherOwner[pl]; ok { + t.Errorf("%s/%s: tools %q and %q both map to the same "+ + "Other-actions entry %q", p.label, method, prev, name, pl.other) + } + otherOwner[pl] = name + otherTools++ + continue + } + cell := placement{product: pl.product, method: pl.method, resource: pl.resource, column: pl.column} + if prev, ok := cellOwner[cell]; ok { + t.Errorf("%s/%s: tools %q and %q both map to matrix cell "+ + "[%s / %s] β€” one would be silently dropped from the doc", + p.label, method, prev, name, pl.resource, pl.column) + } + cellOwner[cell] = name + matrixTools++ + } + } + } + + // Every distinct matrix cell renders as exactly one tick. With no collisions + // (asserted above) the number of ticks must equal the number of matrix tools. + if renderedTicks != len(cellOwner) { + t.Errorf("rendered βœ“ ticks (%d) != distinct matrix cells (%d): a tick "+ + "does not trace back to a tool, or vice versa", renderedTicks, len(cellOwner)) + } + if matrixTools != len(cellOwner) { + t.Errorf("matrix tools (%d) != distinct matrix cells (%d): a tool was "+ + "silently dropped from the CRUD matrix", matrixTools, len(cellOwner)) + } + + // Every "Other actions" tool must appear verbatim in the rendered doc. + for pl, name := range otherOwner { + if !strings.Contains(generated, "`"+pl.other+"`") { + t.Errorf("%s/%s: Other-actions tool %q (slug %q) is not present in the "+ + "rendered doc", pl.product, pl.method, name, pl.other) + } + } + if otherTools != len(otherOwner) { + t.Errorf("other tools (%d) != distinct Other-actions entries (%d)", otherTools, len(otherOwner)) + } +} + +// TestAnnotationCrossCheck is the ANNOTATION CROSS-CHECK guard: a tool placed in +// a Get/List column must be annotated read-only, and a tool placed in a +// Create/Update column must not be. This catches a mis-verb-named tool landing +// in the wrong column (e.g. a write tool named "get_*"). "Other actions" tools +// may be either read-only or write, so they are not asserted. +func TestAnnotationCrossCheck(t *testing.T) { + for _, p := range products() { + for method, ts := range p.group.Toolsets { + for _, tw := range ts.GetAvailableTools() { + name := tw.Tool.Name + pl := classify(p.label, method, name) + if pl.isOther() { + continue + } + if tw.Tool.Annotations == nil { + t.Errorf("%s/%s: tool %q has no annotations; cannot verify "+ + "read-only hint for column %q", p.label, method, name, pl.column) + continue + } + readOnly := tw.Tool.Annotations.ReadOnlyHint + switch pl.column { + case "Get", "List": + if !readOnly { + t.Errorf("%s/%s: tool %q is in the %q column but ReadOnlyHint=false; "+ + "a write tool is mis-named with a read verb", p.label, method, name, pl.column) + } + case "Create", "Update": + if readOnly { + t.Errorf("%s/%s: tool %q is in the %q column but ReadOnlyHint=true; "+ + "a read tool is mis-named with a write verb", p.label, method, name, pl.column) + } + } + } + } + } +} + +// TestNoDeleteToolsExposed is the NO-DELETE guard: the doc-gen groups are built +// with allowDelete=false (mirroring the shipped servers), so no exposed tool +// name may contain "delete". If this fires, a shipped-config assumption changed +// and the doc's "deletes are not exposed" note has gone stale. +func TestNoDeleteToolsExposed(t *testing.T) { + for _, p := range products() { + for method, ts := range p.group.Toolsets { + for _, tw := range ts.GetAvailableTools() { + if strings.Contains(tw.Tool.Name, "delete") { + t.Errorf("%s/%s: delete tool %q is exposed by a doc-gen group built "+ + "with allowDelete=false β€” the doc's 'deletes are not exposed' note is now false", + p.label, method, tw.Tool.Name) + } + } + } + } +} + +// TestDeleteToolsExistButGated proves the counterpart to TestNoDeleteToolsExposed: +// delete tools DO exist in the codebase and appear once allowDelete=true, so the +// NO-DELETE guard is meaningful (it verifies gating, not mere absence). +// +// The doc-gen groups intentionally pass allowDelete=false to mirror the shipped +// servers. If you change that assumption, also update the header note in +// writeHeader and the corresponding calls in cmd/mcp-http/main.go and +// cmd/mcp-stdio/main.go, which are the actual sources of the shipped default. +func TestDeleteToolsExistButGated(t *testing.T) { + group := twprojects.DefaultToolsetGroup(false, true, nil) + var found int + for _, ts := range group.Toolsets { + for _, tw := range ts.GetAvailableTools() { + if strings.Contains(tw.Tool.Name, "delete") { + found++ + } + } + } + if found == 0 { + t.Fatal("expected delete tools to appear with allowDelete=true; either they " + + "were removed or the gating changed β€” the NO-DELETE guard would be vacuous") + } +} diff --git a/docs/tool-reference.md b/docs/tool-reference.md new file mode 100644 index 0000000..aaa5a1a --- /dev/null +++ b/docs/tool-reference.md @@ -0,0 +1,179 @@ +# Teamwork MCP β€” Tool Reference + +Auto-generated from the registered toolsets by `cmd/docs-gen`. Do not edit by hand β€” run `go run ./cmd/docs-gen` to regenerate. + +This reflects the tools a client actually receives from the shipped servers (`cmd/mcp-http`, `cmd/mcp-stdio`) with writes enabled. **Delete operations are intentionally omitted**: they exist in the codebase but are gated behind an `allowDelete` flag that no shipped server enables, so no client can invoke them. Running a server with `-read-only` removes the write tools, leaving the Get/List operations plus any read-only entries under "Other actions" (e.g. `search`, `summarize_timelogs`, `users_workload`). + +## Projects + +### Content β€” `twprojects-content` + +Comments, notebooks, milestones, tags, and activity feeds in Teamwork.com. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Activity | β€” | β€” | βœ“ | β€” | +| Comment | βœ“ | βœ“ | βœ“ | βœ“ | +| Milestone | βœ“ | βœ“ | βœ“ | βœ“ | +| Notebook | βœ“ | βœ“ | βœ“ | βœ“ | +| Tag | βœ“ | βœ“ | βœ“ | βœ“ | +| Message | βœ“ | βœ“ | βœ“ | βœ“ | +| Message Reply | βœ“ | βœ“ | βœ“ | βœ“ | +| Link | βœ“ | βœ“ | βœ“ | βœ“ | + +**Other actions:** `search` + +### People β€” `twprojects-people` + +Users, companies, teams, skills, job roles, and workload management in Teamwork.com. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Company | βœ“ | βœ“ | βœ“ | βœ“ | +| Industry | β€” | β€” | βœ“ | β€” | +| Job Role | βœ“ | βœ“ | βœ“ | βœ“ | +| Skill | βœ“ | βœ“ | βœ“ | βœ“ | +| Team | βœ“ | βœ“ | βœ“ | βœ“ | +| User | βœ“ | βœ“ | βœ“ | βœ“ | +| Current User (me) | β€” | βœ“ | β€” | β€” | + +**Other actions:** `users_workload` + +### Projects β€” `twprojects-projects` + +Project, category, template, member, custom field, and custom item (user-defined entity types like Contracts, Leads, Deals) management in Teamwork.com. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Project Category | βœ“ | βœ“ | βœ“ | βœ“ | +| Project | βœ“ | βœ“ | βœ“ | βœ“ | +| Project Template | βœ“ | β€” | βœ“ | β€” | +| Custom Field | βœ“ | βœ“ | βœ“ | βœ“ | +| Custom Field Value | βœ“ | βœ“ | βœ“ | βœ“ | +| Custom Item | βœ“ | βœ“ | βœ“ | βœ“ | +| Custom Item Field | βœ“ | βœ“ | βœ“ | βœ“ | +| Custom Item Record | βœ“ | βœ“ | βœ“ | βœ“ | + +**Other actions:** `add_project_member`, `clone_project` + +### Tasks β€” `twprojects-tasks` + +Task, tasklist, and workflow management in Teamwork.com. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Task | βœ“ | βœ“ | βœ“ | βœ“ | +| Tasklist | βœ“ | βœ“ | βœ“ | βœ“ | +| Workflow | βœ“ | βœ“ | βœ“ | βœ“ | +| Workflow Stage | βœ“ | βœ“ | βœ“ | βœ“ | + +**Other actions:** `complete_task`, `link_project_to_workflow`, `move_task_to_workflow_stage` + +### Time β€” `twprojects-time` + +Time tracking via timelogs, timers, calendars with time blocking, and budget reporting in Teamwork.com. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Calendar Event | β€” | β€” | βœ“ | β€” | +| Calendar | β€” | β€” | βœ“ | β€” | +| Project Budget | β€” | β€” | βœ“ | β€” | +| Tasklist Budget | β€” | β€” | βœ“ | β€” | +| Timelog | βœ“ | βœ“ | βœ“ | βœ“ | +| Timer | βœ“ | βœ“ | βœ“ | βœ“ | + +**Other actions:** `complete_timer`, `pause_timer`, `resume_timer`, `summarize_timelogs` + +## Desk + +### Admin β€” `twdesk-admin` + +Inbox configuration: priorities, statuses, types, and tags in Teamwork Desk. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Priority | βœ“ | βœ“ | βœ“ | βœ“ | +| Status | βœ“ | βœ“ | βœ“ | βœ“ | +| Tag | βœ“ | βœ“ | βœ“ | βœ“ | +| Ticket Type | βœ“ | βœ“ | βœ“ | βœ“ | + +### Customers β€” `twdesk-customers` + +Companies, customers, and user management in Teamwork Desk. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Company | βœ“ | βœ“ | βœ“ | βœ“ | +| Customer | βœ“ | βœ“ | βœ“ | βœ“ | +| User | β€” | βœ“ | βœ“ | β€” | + +### Helpdocs β€” `twdesk-helpdocs` + +Help doc articles in Teamwork Desk. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Helpdoc Article | βœ“ | βœ“ | β€” | βœ“ | + +**Other actions:** `search_helpdoc_articles` + +### Tickets β€” `twdesk-tickets` + +Tickets, messages, files, and inboxes in Teamwork Desk. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Inbox | β€” | βœ“ | βœ“ | β€” | +| Ticket | βœ“ | βœ“ | β€” | βœ“ | +| File | βœ“ | β€” | β€” | β€” | + +**Other actions:** `reply_ticket`, `search_tickets` + +## Spaces + +### Content β€” `twspaces-content` + +Comments, tags, categories, and search in Teamwork Spaces. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Comment | βœ“ | βœ“ | βœ“ | βœ“ | +| Tag | βœ“ | βœ“ | βœ“ | βœ“ | +| Category | βœ“ | βœ“ | βœ“ | βœ“ | + +**Other actions:** `search` + +### Pages β€” `twspaces-pages` + +Page CRUD, homepage, and duplication in Teamwork Spaces. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Page | βœ“ | βœ“ | βœ“ | βœ“ | +| Homepage | β€” | βœ“ | β€” | β€” | + +**Other actions:** `duplicate_page` + +### Spaces β€” `twspaces-spaces` + +Space CRUD and collaborators in Teamwork Spaces. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Space | βœ“ | βœ“ | βœ“ | βœ“ | +| Space Collaborator | β€” | β€” | βœ“ | β€” | + +## Chat + +### Chat β€” `twchat-chat` + +Read conversations, messages, and people, and send messages in Teamwork Chat. + +| Resource | Create | Get | List | Update | +|---|---|---|---|---| +| Current User | β€” | βœ“ | β€” | β€” | +| Conversation | β€” | βœ“ | βœ“ | β€” | +| Message | β€” | β€” | βœ“ | β€” | +| People | β€” | β€” | βœ“ | β€” | + +**Other actions:** `get_or_create_dm`, `send_dm`, `send_message`