refactor: use direct Create/Update on builders

Amolith created

- Add generic CRUD helpers in crud.go to reduce duplication
- Move builders from builders.go into their respective domain files
- Builders now call Create/Update directly instead of returning request
  structs
- Make request types unexported (createTaskRequest, etc.)
- Update examples in README and AGENTS.md

Assisted-by: Claude Sonnet 4 via Crush

Change summary

AGENTS.md   |   2 
README.md   |   6 
builders.go | 406 -------------------------------------------------------
crud.go     | 118 +++++++++++++++
journal.go  |  41 ++++
notes.go    | 175 ++++++++++++++---------
people.go   | 132 ++++++++++-------
tasks.go    | 277 ++++++++++++++++++++++++++++---------
timeline.go |  47 +++++-
9 files changed, 585 insertions(+), 619 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -24,7 +24,7 @@ task                            # Include vulnerability and copyright checks
 Create methods return `(nil, nil)` when a matching entity already exists (HTTP 204). This is intentional API behavior, not an error:
 
 ```go
-task, err := client.CreateTask(ctx, req)
+task, err := lunatask.NewTask("Review PR").Create(ctx, client)
 if err != nil {
     return err // actual error
 }

README.md 🔗

@@ -52,9 +52,7 @@ func main() {
 	}
 
 	// Create a task
-	task, err := client.CreateTask(context.Background(), &lunatask.CreateTaskRequest{
-		Name: "Review pull requests",
-	})
+	task, err := lunatask.NewTask("Review pull requests").Create(context.Background(), client)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -71,7 +69,7 @@ exists. This is intentional API behavior on Lunatask's part because of
 its end-to-end encryption.
 
 ```go
-task, err := client.CreateTask(ctx, req)
+task, err := lunatask.NewTask("Review PR").Create(ctx, client)
 if err != nil {
     return err // actual error
 }

builders.go 🔗

@@ -1,406 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package lunatask
-
-import "time"
-
-// TaskBuilder constructs a [CreateTaskRequest] via method chaining.
-//
-//	req := lunatask.NewTask("Review PR").
-//		InArea(areaID).
-//		WithStatus(lunatask.StatusNext).
-//		WithEstimate(30).
-//		Build()
-//	task, err := client.CreateTask(ctx, req)
-type TaskBuilder struct {
-	req CreateTaskRequest
-}
-
-// NewTask starts building a task with the given name.
-func NewTask(name string) *TaskBuilder {
-	return &TaskBuilder{req: CreateTaskRequest{Name: name}} //nolint:exhaustruct
-}
-
-// InArea assigns the task to an area. IDs are in the area's settings in the app.
-func (b *TaskBuilder) InArea(areaID string) *TaskBuilder {
-	b.req.AreaID = &areaID
-
-	return b
-}
-
-// InGoal assigns the task to a goal. IDs are in the goal's settings in the app.
-func (b *TaskBuilder) InGoal(goalID string) *TaskBuilder {
-	b.req.GoalID = &goalID
-
-	return b
-}
-
-// WithNote attaches a Markdown note to the task.
-func (b *TaskBuilder) WithNote(note string) *TaskBuilder {
-	b.req.Note = &note
-
-	return b
-}
-
-// WithStatus sets the workflow status.
-// Use one of the Status* constants (e.g., [StatusNext]).
-func (b *TaskBuilder) WithStatus(status TaskStatus) *TaskBuilder {
-	b.req.Status = &status
-
-	return b
-}
-
-// WithMotivation sets why this task matters.
-// Use one of the Motivation* constants (e.g., [MotivationMust]).
-func (b *TaskBuilder) WithMotivation(motivation Motivation) *TaskBuilder {
-	b.req.Motivation = &motivation
-
-	return b
-}
-
-// WithEstimate sets the expected duration in minutes (0–720).
-func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder {
-	b.req.Estimate = &minutes
-
-	return b
-}
-
-// WithPriority sets importance from -2 (lowest) to 2 (highest).
-func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder {
-	b.req.Priority = &priority
-
-	return b
-}
-
-// WithEisenhower sets the matrix quadrant (0–4).
-func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder {
-	b.req.Eisenhower = &eisenhower
-
-	return b
-}
-
-// ScheduledOn sets when the task should appear on your schedule.
-func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder {
-	b.req.ScheduledOn = &date
-
-	return b
-}
-
-// CompletedAt marks the task completed at a specific time.
-func (b *TaskBuilder) CompletedAt(t time.Time) *TaskBuilder {
-	b.req.CompletedAt = &t
-
-	return b
-}
-
-// FromSource tags the task with a free-form origin identifier, useful for
-// tracking tasks created by scripts or external integrations.
-func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder {
-	b.req.Source = &source
-	b.req.SourceID = &sourceID
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *TaskBuilder) Build() *CreateTaskRequest {
-	return &b.req
-}
-
-// TaskUpdateBuilder constructs an [UpdateTaskRequest] via method chaining.
-// Only fields you set will be modified; others remain unchanged.
-//
-//	req := lunatask.NewTaskUpdate().
-//		WithStatus(lunatask.StatusCompleted).
-//		CompletedAt(time.Now()).
-//		Build()
-//	task, err := client.UpdateTask(ctx, taskID, req)
-type TaskUpdateBuilder struct {
-	req UpdateTaskRequest
-}
-
-// NewTaskUpdate starts building a task update.
-func NewTaskUpdate() *TaskUpdateBuilder {
-	return &TaskUpdateBuilder{} //nolint:exhaustruct
-}
-
-// Name changes the task's name.
-func (b *TaskUpdateBuilder) Name(name string) *TaskUpdateBuilder {
-	b.req.Name = &name
-
-	return b
-}
-
-// InArea moves the task to an area. IDs are in the area's settings in the app.
-func (b *TaskUpdateBuilder) InArea(areaID string) *TaskUpdateBuilder {
-	b.req.AreaID = &areaID
-
-	return b
-}
-
-// InGoal moves the task to a goal. IDs are in the goal's settings in the app.
-func (b *TaskUpdateBuilder) InGoal(goalID string) *TaskUpdateBuilder {
-	b.req.GoalID = &goalID
-
-	return b
-}
-
-// WithNote replaces the task's Markdown note.
-func (b *TaskUpdateBuilder) WithNote(note string) *TaskUpdateBuilder {
-	b.req.Note = &note
-
-	return b
-}
-
-// WithStatus sets the workflow status.
-// Use one of the Status* constants (e.g., [StatusNext]).
-func (b *TaskUpdateBuilder) WithStatus(status TaskStatus) *TaskUpdateBuilder {
-	b.req.Status = &status
-
-	return b
-}
-
-// WithMotivation sets why this task matters.
-// Use one of the Motivation* constants (e.g., [MotivationMust]).
-func (b *TaskUpdateBuilder) WithMotivation(motivation Motivation) *TaskUpdateBuilder {
-	b.req.Motivation = &motivation
-
-	return b
-}
-
-// WithEstimate sets the expected duration in minutes (0–720).
-func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder {
-	b.req.Estimate = &minutes
-
-	return b
-}
-
-// WithPriority sets importance from -2 (lowest) to 2 (highest).
-func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder {
-	b.req.Priority = &priority
-
-	return b
-}
-
-// WithEisenhower sets the matrix quadrant (0–4).
-func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder {
-	b.req.Eisenhower = &eisenhower
-
-	return b
-}
-
-// ScheduledOn sets when the task should appear on your schedule.
-func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder {
-	b.req.ScheduledOn = &date
-
-	return b
-}
-
-// CompletedAt marks the task completed at a specific time.
-func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder {
-	b.req.CompletedAt = &t
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *TaskUpdateBuilder) Build() *UpdateTaskRequest {
-	return &b.req
-}
-
-// NoteBuilder constructs a [CreateNoteRequest] via method chaining.
-// Note fields are encrypted client-side by Lunatask; the API accepts them
-// on create but returns null on read.
-//
-//	req := lunatask.NewNote().
-//		WithName("Meeting notes").
-//		WithContent("# Summary\n\n...").
-//		InNotebook(notebookID).
-//		Build()
-//	note, err := client.CreateNote(ctx, req)
-type NoteBuilder struct {
-	req CreateNoteRequest
-}
-
-// NewNote starts building a note.
-func NewNote() *NoteBuilder {
-	return &NoteBuilder{} //nolint:exhaustruct
-}
-
-// WithName sets the note's title.
-func (b *NoteBuilder) WithName(name string) *NoteBuilder {
-	b.req.Name = &name
-
-	return b
-}
-
-// WithContent sets the Markdown body.
-func (b *NoteBuilder) WithContent(content string) *NoteBuilder {
-	b.req.Content = &content
-
-	return b
-}
-
-// InNotebook places the note in a notebook. IDs are in the notebook's settings in the app.
-func (b *NoteBuilder) InNotebook(notebookID string) *NoteBuilder {
-	b.req.NotebookID = &notebookID
-
-	return b
-}
-
-// FromSource tags the note with a free-form origin identifier, useful for
-// tracking notes created by scripts or external integrations.
-func (b *NoteBuilder) FromSource(source, sourceID string) *NoteBuilder {
-	b.req.Source = &source
-	b.req.SourceID = &sourceID
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *NoteBuilder) Build() *CreateNoteRequest {
-	return &b.req
-}
-
-// JournalEntryBuilder constructs a [CreateJournalEntryRequest] via method chaining.
-// Journal content is encrypted client-side; the API accepts it on create but
-// returns null on read.
-//
-//	req := lunatask.NewJournalEntry(lunatask.Today()).
-//		WithContent("Shipped the new feature!").
-//		Build()
-//	entry, err := client.CreateJournalEntry(ctx, req)
-type JournalEntryBuilder struct {
-	req CreateJournalEntryRequest
-}
-
-// NewJournalEntry starts building a journal entry for the given date.
-func NewJournalEntry(date Date) *JournalEntryBuilder {
-	return &JournalEntryBuilder{req: CreateJournalEntryRequest{DateOn: date}} //nolint:exhaustruct
-}
-
-// WithName sets the entry's title. Defaults to the weekday name if omitted.
-func (b *JournalEntryBuilder) WithName(name string) *JournalEntryBuilder {
-	b.req.Name = &name
-
-	return b
-}
-
-// WithContent sets the Markdown body.
-func (b *JournalEntryBuilder) WithContent(content string) *JournalEntryBuilder {
-	b.req.Content = &content
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *JournalEntryBuilder) Build() *CreateJournalEntryRequest {
-	return &b.req
-}
-
-// PersonBuilder constructs a [CreatePersonRequest] via method chaining.
-// Name fields are encrypted client-side; the API accepts them on create but
-// returns null on read.
-//
-//	req := lunatask.NewPerson().
-//		WithFirstName("Ada").
-//		WithLastName("Lovelace").
-//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
-//		Build()
-//	person, err := client.CreatePerson(ctx, req)
-type PersonBuilder struct {
-	req CreatePersonRequest
-}
-
-// NewPerson starts building a person entry.
-func NewPerson() *PersonBuilder {
-	return &PersonBuilder{} //nolint:exhaustruct
-}
-
-// WithFirstName sets the person's first name.
-func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder {
-	b.req.FirstName = &name
-
-	return b
-}
-
-// WithLastName sets the person's last name.
-func (b *PersonBuilder) WithLastName(name string) *PersonBuilder {
-	b.req.LastName = &name
-
-	return b
-}
-
-// WithRelationshipStrength categorizes the closeness of the relationship.
-// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
-func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
-	b.req.RelationshipStrength = &strength
-
-	return b
-}
-
-// FromSource tags the person with a free-form origin identifier, useful for
-// tracking entries created by scripts or external integrations.
-func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
-	b.req.Source = &source
-	b.req.SourceID = &sourceID
-
-	return b
-}
-
-// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
-// "birthday", and "phone" out of the box; other fields must first be defined
-// in the app or [ErrUnprocessableEntity] is returned.
-func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
-	if b.req.CustomFields == nil {
-		b.req.CustomFields = make(map[string]any)
-	}
-
-	b.req.CustomFields[key] = value
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *PersonBuilder) Build() *CreatePersonRequest {
-	return &b.req
-}
-
-// TimelineNoteBuilder constructs a [CreatePersonTimelineNoteRequest] via method chaining.
-// Content is encrypted client-side; the API accepts it on create but returns null on read.
-//
-//	req := lunatask.NewTimelineNote(personID).
-//		OnDate(lunatask.Today()).
-//		WithContent("Had coffee, discussed the project.").
-//		Build()
-//	note, err := client.CreatePersonTimelineNote(ctx, req)
-type TimelineNoteBuilder struct {
-	req CreatePersonTimelineNoteRequest
-}
-
-// NewTimelineNote starts building a timeline note for the given person.
-// Get person IDs from [Client.ListPeople].
-func NewTimelineNote(personID string) *TimelineNoteBuilder {
-	return &TimelineNoteBuilder{req: CreatePersonTimelineNoteRequest{PersonID: personID}} //nolint:exhaustruct
-}
-
-// OnDate sets when this interaction occurred.
-func (b *TimelineNoteBuilder) OnDate(date Date) *TimelineNoteBuilder {
-	b.req.DateOn = &date
-
-	return b
-}
-
-// WithContent sets the Markdown body describing the interaction.
-func (b *TimelineNoteBuilder) WithContent(content string) *TimelineNoteBuilder {
-	b.req.Content = &content
-
-	return b
-}
-
-// Build returns the constructed request.
-func (b *TimelineNoteBuilder) Build() *CreatePersonTimelineNoteRequest {
-	return &b.req
-}

crud.go 🔗

@@ -0,0 +1,118 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+// unwrap extracts an entity from a response wrapper.
+type unwrap[E, R any] func(R) E
+
+// unwrapSlice extracts a slice of entities from a response wrapper.
+type unwrapSlice[E, R any] func(R) []E
+
+// SourceFilter provides optional source filtering for list operations.
+type SourceFilter interface {
+	GetSource() *string
+	GetSourceID() *string
+}
+
+// get fetches a single entity by ID.
+func get[E, R any](ctx context.Context, c *Client, path, id, name string, extract unwrap[E, R]) (*E, error) {
+	if id == "" {
+		return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name)
+	}
+
+	resp, _, err := doJSON[R](ctx, c, http.MethodGet, path+"/"+id, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	e := extract(*resp)
+
+	return &e, nil
+}
+
+// list fetches all entities, optionally filtered by source.
+func list[E, R any](
+	ctx context.Context, c *Client, path string, opts SourceFilter, extract unwrapSlice[E, R],
+) ([]E, error) {
+	if opts != nil {
+		params := url.Values{}
+
+		if s := opts.GetSource(); s != nil && *s != "" {
+			params.Set("source", *s)
+		}
+
+		if s := opts.GetSourceID(); s != nil && *s != "" {
+			params.Set("source_id", *s)
+		}
+
+		if len(params) > 0 {
+			path = fmt.Sprintf("%s?%s", path, params.Encode())
+		}
+	}
+
+	resp, _, err := doJSON[R](ctx, c, http.MethodGet, path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return extract(*resp), nil
+}
+
+// create adds a new entity. Returns (nil, nil) if duplicate exists (HTTP 204).
+func create[E, R any](ctx context.Context, c *Client, path string, body any, extract unwrap[E, R]) (*E, error) {
+	resp, noContent, err := doJSON[R](ctx, c, http.MethodPost, path, body)
+	if err != nil {
+		return nil, err
+	}
+
+	if noContent {
+		return nil, nil //nolint:nilnil
+	}
+
+	e := extract(*resp)
+
+	return &e, nil
+}
+
+// update modifies an entity by ID.
+func update[E, R any](
+	ctx context.Context, c *Client, path, id, name string, body any, extract unwrap[E, R],
+) (*E, error) {
+	if id == "" {
+		return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name)
+	}
+
+	resp, _, err := doJSON[R](ctx, c, http.MethodPut, path+"/"+id, body)
+	if err != nil {
+		return nil, err
+	}
+
+	e := extract(*resp)
+
+	return &e, nil
+}
+
+// del removes an entity by ID and returns its final state.
+func del[E, R any](ctx context.Context, c *Client, path, id, name string, extract unwrap[E, R]) (*E, error) {
+	if id == "" {
+		return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name)
+	}
+
+	resp, _, err := doJSON[R](ctx, c, http.MethodDelete, path+"/"+id, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	e := extract(*resp)
+
+	return &e, nil
+}

journal.go 🔗

@@ -19,9 +19,8 @@ type JournalEntry struct {
 	UpdatedAt time.Time `json:"updated_at"`
 }
 
-// CreateJournalEntryRequest defines a new journal entry.
-// Use [JournalEntryBuilder] for a fluent construction API.
-type CreateJournalEntryRequest struct {
+// createJournalEntryRequest defines a new journal entry for JSON serialization.
+type createJournalEntryRequest struct {
 	// DateOn is the date for the journal entry (required).
 	DateOn Date `json:"date_on"`
 	// Name is the title for the entry (optional, defaults to weekday name like "Tuesday, July 1st").
@@ -35,10 +34,40 @@ type journalEntryResponse struct {
 	JournalEntry JournalEntry `json:"journal_entry"`
 }
 
-// CreateJournalEntry adds a journal entry. Returns the created entry's metadata;
+// JournalEntryBuilder constructs and creates a journal entry via method chaining.
+// Journal content is encrypted client-side; the API accepts it on create but
+// returns null on read.
+//
+//	entry, err := lunatask.NewJournalEntry(lunatask.Today()).
+//		WithContent("Shipped the new feature!").
+//		Create(ctx, client)
+type JournalEntryBuilder struct {
+	req createJournalEntryRequest
+}
+
+// NewJournalEntry starts building a journal entry for the given date.
+func NewJournalEntry(date Date) *JournalEntryBuilder {
+	return &JournalEntryBuilder{req: createJournalEntryRequest{DateOn: date}} //nolint:exhaustruct
+}
+
+// WithName sets the entry's title. Defaults to the weekday name if omitted.
+func (b *JournalEntryBuilder) WithName(name string) *JournalEntryBuilder {
+	b.req.Name = &name
+
+	return b
+}
+
+// WithContent sets the Markdown body.
+func (b *JournalEntryBuilder) WithContent(content string) *JournalEntryBuilder {
+	b.req.Content = &content
+
+	return b
+}
+
+// Create sends the journal entry to Lunatask. Returns the created entry's metadata;
 // Name and Content won't round-trip due to E2EE.
-func (c *Client) CreateJournalEntry(ctx context.Context, entry *CreateJournalEntryRequest) (*JournalEntry, error) {
-	resp, _, err := doJSON[journalEntryResponse](ctx, c, http.MethodPost, "/journal_entries", entry)
+func (b *JournalEntryBuilder) Create(ctx context.Context, c *Client) (*JournalEntry, error) {
+	resp, _, err := doJSON[journalEntryResponse](ctx, c, http.MethodPost, "/journal_entries", b.req)
 	if err != nil {
 		return nil, err
 	}

notes.go 🔗

@@ -6,9 +6,6 @@ package lunatask
 
 import (
 	"context"
-	"fmt"
-	"net/http"
-	"net/url"
 	"time"
 )
 
@@ -23,9 +20,8 @@ type Note struct {
 	UpdatedAt  time.Time `json:"updated_at"`
 }
 
-// CreateNoteRequest defines a new note.
-// Use [NoteBuilder] for a fluent construction API.
-type CreateNoteRequest struct {
+// createNoteRequest defines a new note for JSON serialization.
+type createNoteRequest struct {
 	Name       *string `json:"name,omitempty"`
 	Content    *string `json:"content,omitempty"`
 	NotebookID *string `json:"notebook_id,omitempty"`
@@ -33,9 +29,8 @@ type CreateNoteRequest struct {
 	SourceID   *string `json:"source_id,omitempty"`
 }
 
-// UpdateNoteRequest specifies which fields to change on a note.
-// Only non-nil fields are updated. Content replaces entirely (E2EE prevents appending).
-type UpdateNoteRequest struct {
+// updateNoteRequest specifies which fields to change on a note.
+type updateNoteRequest struct {
 	Name       *string `json:"name,omitempty"`
 	Content    *string `json:"content,omitempty"`
 	NotebookID *string `json:"notebook_id,omitempty"`
@@ -58,87 +53,131 @@ type ListNotesOptions struct {
 	SourceID *string
 }
 
+// GetSource implements [SourceFilter].
+func (o *ListNotesOptions) GetSource() *string { return o.Source }
+
+// GetSourceID implements [SourceFilter].
+func (o *ListNotesOptions) GetSourceID() *string { return o.SourceID }
+
 // ListNotes returns all notes, optionally filtered. Pass nil for all notes.
 func (c *Client) ListNotes(ctx context.Context, opts *ListNotesOptions) ([]Note, error) {
-	path := "/notes"
-
+	var filter SourceFilter
 	if opts != nil {
-		params := url.Values{}
-		if opts.Source != nil && *opts.Source != "" {
-			params.Set("source", *opts.Source)
-		}
-
-		if opts.SourceID != nil && *opts.SourceID != "" {
-			params.Set("source_id", *opts.SourceID)
-		}
-
-		if len(params) > 0 {
-			path = fmt.Sprintf("%s?%s", path, params.Encode())
-		}
+		filter = opts
 	}
 
-	resp, _, err := doJSON[notesResponse](ctx, c, http.MethodGet, path, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Notes, nil
+	return list(ctx, c, "/notes", filter, func(r notesResponse) []Note { return r.Notes })
 }
 
 // GetNote fetches a note by ID. Name and Content will be null (E2EE).
 func (c *Client) GetNote(ctx context.Context, noteID string) (*Note, error) {
-	if noteID == "" {
-		return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest)
-	}
+	return get(ctx, c, "/notes", noteID, "note", func(r noteResponse) Note { return r.Note })
+}
 
-	resp, _, err := doJSON[noteResponse](ctx, c, http.MethodGet, "/notes/"+noteID, nil)
-	if err != nil {
-		return nil, err
-	}
+// DeleteNote removes a note and returns its final state.
+func (c *Client) DeleteNote(ctx context.Context, noteID string) (*Note, error) {
+	return del(ctx, c, "/notes", noteID, "note", func(r noteResponse) Note { return r.Note })
+}
 
-	return &resp.Note, nil
+// NoteBuilder constructs and creates a note via method chaining.
+// Note fields are encrypted client-side by Lunatask; the API accepts them
+// on create but returns null on read.
+//
+//	note, err := lunatask.NewNote().
+//		WithName("Meeting notes").
+//		WithContent("# Summary\n\n...").
+//		InNotebook(notebookID).
+//		Create(ctx, client)
+type NoteBuilder struct {
+	req createNoteRequest
+}
+
+// NewNote starts building a note.
+func NewNote() *NoteBuilder {
+	return &NoteBuilder{} //nolint:exhaustruct
+}
+
+// WithName sets the note's title.
+func (b *NoteBuilder) WithName(name string) *NoteBuilder {
+	b.req.Name = &name
+
+	return b
 }
 
-// CreateNote adds a note. Returns (nil, nil) if a duplicate exists
+// WithContent sets the Markdown body.
+func (b *NoteBuilder) WithContent(content string) *NoteBuilder {
+	b.req.Content = &content
+
+	return b
+}
+
+// InNotebook places the note in a notebook. IDs are in the notebook's settings in the app.
+func (b *NoteBuilder) InNotebook(notebookID string) *NoteBuilder {
+	b.req.NotebookID = &notebookID
+
+	return b
+}
+
+// FromSource tags the note with a free-form origin identifier, useful for
+// tracking notes created by scripts or external integrations.
+func (b *NoteBuilder) FromSource(source, sourceID string) *NoteBuilder {
+	b.req.Source = &source
+	b.req.SourceID = &sourceID
+
+	return b
+}
+
+// Create sends the note to Lunatask. Returns (nil, nil) if a duplicate exists
 // in the same notebook with matching source/source_id.
-func (c *Client) CreateNote(ctx context.Context, note *CreateNoteRequest) (*Note, error) {
-	resp, noContent, err := doJSON[noteResponse](ctx, c, http.MethodPost, "/notes", note)
-	if err != nil {
-		return nil, err
-	}
+func (b *NoteBuilder) Create(ctx context.Context, c *Client) (*Note, error) {
+	return create(ctx, c, "/notes", b.req, func(r noteResponse) Note { return r.Note })
+}
 
-	if noContent {
-		// Intentional: duplicate exists (HTTP 204), not an error
-		return nil, nil //nolint:nilnil
-	}
+// NoteUpdateBuilder constructs and updates a note via method chaining.
+// Only fields you set will be modified; others remain unchanged.
+//
+//	note, err := lunatask.NewNoteUpdate(noteID).
+//		WithContent("# Updated content").
+//		Update(ctx, client)
+type NoteUpdateBuilder struct {
+	noteID string
+	req    updateNoteRequest
+}
 
-	return &resp.Note, nil
+// NewNoteUpdate starts building a note update for the given note ID.
+func NewNoteUpdate(noteID string) *NoteUpdateBuilder {
+	return &NoteUpdateBuilder{noteID: noteID} //nolint:exhaustruct
 }
 
-// UpdateNote modifies a note. Only non-nil fields in the request are changed.
-func (c *Client) UpdateNote(ctx context.Context, noteID string, note *UpdateNoteRequest) (*Note, error) {
-	if noteID == "" {
-		return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest)
-	}
+// WithName sets the note's title.
+func (b *NoteUpdateBuilder) WithName(name string) *NoteUpdateBuilder {
+	b.req.Name = &name
 
-	resp, _, err := doJSON[noteResponse](ctx, c, http.MethodPut, "/notes/"+noteID, note)
-	if err != nil {
-		return nil, err
-	}
+	return b
+}
+
+// WithContent sets the Markdown body.
+func (b *NoteUpdateBuilder) WithContent(content string) *NoteUpdateBuilder {
+	b.req.Content = &content
 
-	return &resp.Note, nil
+	return b
 }
 
-// DeleteNote removes a note and returns its final state.
-func (c *Client) DeleteNote(ctx context.Context, noteID string) (*Note, error) {
-	if noteID == "" {
-		return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest)
-	}
+// InNotebook moves the note to a notebook.
+func (b *NoteUpdateBuilder) InNotebook(notebookID string) *NoteUpdateBuilder {
+	b.req.NotebookID = &notebookID
 
-	resp, _, err := doJSON[noteResponse](ctx, c, http.MethodDelete, "/notes/"+noteID, nil)
-	if err != nil {
-		return nil, err
-	}
+	return b
+}
+
+// OnDate sets the date for the note.
+func (b *NoteUpdateBuilder) OnDate(date Date) *NoteUpdateBuilder {
+	b.req.DateOn = &date
+
+	return b
+}
 
-	return &resp.Note, nil
+// Update sends the changes to Lunatask.
+func (b *NoteUpdateBuilder) Update(ctx context.Context, c *Client) (*Note, error) {
+	return update(ctx, c, "/notes", b.noteID, "note", b.req, func(r noteResponse) Note { return r.Note })
 }

people.go 🔗

@@ -9,8 +9,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"maps"
-	"net/http"
-	"net/url"
 	"time"
 )
 
@@ -24,14 +22,13 @@ type Person struct {
 	UpdatedAt            time.Time             `json:"updated_at"`
 }
 
-// CreatePersonRequest defines a new person.
-// Use [PersonBuilder] for a fluent construction API.
+// createPersonRequest defines a new person for JSON serialization.
 //
 // CustomFields allows setting arbitrary custom fields. Lunatask supports
 // "email", "birthday", and "phone" out of the box; other fields must first be
 // defined in the app or [ErrUnprocessableEntity] is returned. These are
 // flattened to top-level JSON fields when marshaled.
-type CreatePersonRequest struct {
+type createPersonRequest struct {
 	FirstName            *string               `json:"first_name,omitempty"`
 	LastName             *string               `json:"last_name,omitempty"`
 	RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
@@ -41,9 +38,9 @@ type CreatePersonRequest struct {
 }
 
 // MarshalJSON flattens CustomFields to top-level JSON fields.
-func (r CreatePersonRequest) MarshalJSON() ([]byte, error) {
+func (r createPersonRequest) MarshalJSON() ([]byte, error) {
 	// Alias to avoid infinite recursion
-	type plain CreatePersonRequest
+	type plain createPersonRequest
 
 	base, err := json.Marshal(plain(r))
 	if err != nil {
@@ -86,73 +83,96 @@ type ListPeopleOptions struct {
 	SourceID *string
 }
 
+// GetSource implements [SourceFilter].
+func (o *ListPeopleOptions) GetSource() *string { return o.Source }
+
+// GetSourceID implements [SourceFilter].
+func (o *ListPeopleOptions) GetSourceID() *string { return o.SourceID }
+
 // ListPeople returns all people, optionally filtered. Pass nil for all.
 func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
-	path := "/people"
-
+	var filter SourceFilter
 	if opts != nil {
-		params := url.Values{}
-		if opts.Source != nil && *opts.Source != "" {
-			params.Set("source", *opts.Source)
-		}
-
-		if opts.SourceID != nil && *opts.SourceID != "" {
-			params.Set("source_id", *opts.SourceID)
-		}
-
-		if len(params) > 0 {
-			path = fmt.Sprintf("%s?%s", path, params.Encode())
-		}
+		filter = opts
 	}
 
-	resp, _, err := doJSON[peopleResponse](ctx, c, http.MethodGet, path, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.People, nil
+	return list(ctx, c, "/people", filter, func(r peopleResponse) []Person { return r.People })
 }
 
 // GetPerson fetches a person by ID. Name fields will be null (E2EE).
 func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
-	if personID == "" {
-		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
-	}
+	return get(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
+}
 
-	resp, _, err := doJSON[personResponse](ctx, c, http.MethodGet, "/people/"+personID, nil)
-	if err != nil {
-		return nil, err
-	}
+// DeletePerson removes a person and returns their final state.
+func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
+	return del(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
+}
 
-	return &resp.Person, nil
+// PersonBuilder constructs and creates a person via method chaining.
+// Name fields are encrypted client-side; the API accepts them on create but
+// returns null on read.
+//
+//	person, err := lunatask.NewPerson().
+//		WithFirstName("Ada").
+//		WithLastName("Lovelace").
+//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
+//		Create(ctx, client)
+type PersonBuilder struct {
+	req createPersonRequest
 }
 
-// CreatePerson adds a person. Returns (nil, nil) if a duplicate exists
-// with matching source/source_id.
-func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) {
-	resp, noContent, err := doJSON[personResponse](ctx, c, http.MethodPost, "/people", person)
-	if err != nil {
-		return nil, err
-	}
+// NewPerson starts building a person entry.
+func NewPerson() *PersonBuilder {
+	return &PersonBuilder{} //nolint:exhaustruct
+}
 
-	if noContent {
-		// Intentional: duplicate exists (HTTP 204), not an error
-		return nil, nil //nolint:nilnil
-	}
+// WithFirstName sets the person's first name.
+func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder {
+	b.req.FirstName = &name
 
-	return &resp.Person, nil
+	return b
 }
 
-// DeletePerson removes a person and returns their final state.
-func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
-	if personID == "" {
-		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
-	}
+// WithLastName sets the person's last name.
+func (b *PersonBuilder) WithLastName(name string) *PersonBuilder {
+	b.req.LastName = &name
 
-	resp, _, err := doJSON[personResponse](ctx, c, http.MethodDelete, "/people/"+personID, nil)
-	if err != nil {
-		return nil, err
+	return b
+}
+
+// WithRelationshipStrength categorizes the closeness of the relationship.
+// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
+func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
+	b.req.RelationshipStrength = &strength
+
+	return b
+}
+
+// FromSource tags the person with a free-form origin identifier, useful for
+// tracking entries created by scripts or external integrations.
+func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
+	b.req.Source = &source
+	b.req.SourceID = &sourceID
+
+	return b
+}
+
+// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
+// "birthday", and "phone" out of the box; other fields must first be defined
+// in the app or [ErrUnprocessableEntity] is returned.
+func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
+	if b.req.CustomFields == nil {
+		b.req.CustomFields = make(map[string]any)
 	}
 
-	return &resp.Person, nil
+	b.req.CustomFields[key] = value
+
+	return b
+}
+
+// Create sends the person to Lunatask. Returns (nil, nil) if a duplicate exists
+// with matching source/source_id.
+func (b *PersonBuilder) Create(ctx context.Context, c *Client) (*Person, error) {
+	return create(ctx, c, "/people", b.req, func(r personResponse) Person { return r.Person })
 }

tasks.go 🔗

@@ -7,8 +7,6 @@ package lunatask
 import (
 	"context"
 	"fmt"
-	"net/http"
-	"net/url"
 	"time"
 )
 
@@ -34,9 +32,8 @@ type Task struct {
 	UpdatedAt      time.Time   `json:"updated_at"`
 }
 
-// CreateTaskRequest defines a new task.
-// Use [TaskBuilder] for a fluent construction API.
-type CreateTaskRequest struct {
+// createTaskRequest defines a new task for JSON serialization.
+type createTaskRequest struct {
 	Name        string      `json:"name"`
 	AreaID      *string     `json:"area_id,omitempty"`
 	GoalID      *string     `json:"goal_id,omitempty"`
@@ -52,9 +49,8 @@ type CreateTaskRequest struct {
 	SourceID    *string     `json:"source_id,omitempty"`
 }
 
-// UpdateTaskRequest specifies which fields to change on a task.
-// Only non-nil fields are updated. Use [TaskUpdateBuilder] for fluent construction.
-type UpdateTaskRequest struct {
+// updateTaskRequest specifies which fields to change on a task.
+type updateTaskRequest struct {
 	Name        *string     `json:"name,omitempty"`
 	AreaID      *string     `json:"area_id,omitempty"`
 	GoalID      *string     `json:"goal_id,omitempty"`
@@ -84,91 +80,236 @@ type ListTasksOptions struct {
 	SourceID *string
 }
 
+// GetSource implements [SourceFilter].
+func (o *ListTasksOptions) GetSource() *string { return o.Source }
+
+// GetSourceID implements [SourceFilter].
+func (o *ListTasksOptions) GetSourceID() *string { return o.SourceID }
+
 // ListTasks returns all tasks, optionally filtered. Pass nil for all.
 func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) {
-	path := "/tasks"
-
+	var filter SourceFilter
 	if opts != nil {
-		params := url.Values{}
-		if opts.Source != nil && *opts.Source != "" {
-			params.Set("source", *opts.Source)
-		}
-
-		if opts.SourceID != nil && *opts.SourceID != "" {
-			params.Set("source_id", *opts.SourceID)
-		}
-
-		if len(params) > 0 {
-			path = fmt.Sprintf("%s?%s", path, params.Encode())
-		}
+		filter = opts
 	}
 
-	resp, _, err := doJSON[tasksResponse](ctx, c, http.MethodGet, path, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Tasks, nil
+	return list(ctx, c, "/tasks", filter, func(r tasksResponse) []Task { return r.Tasks })
 }
 
 // GetTask fetches a task by ID. Name and Note will be null (E2EE).
 func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) {
-	if taskID == "" {
-		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
-	}
+	return get(ctx, c, "/tasks", taskID, "task", func(r taskResponse) Task { return r.Task })
+}
 
-	resp, _, err := doJSON[taskResponse](ctx, c, http.MethodGet, "/tasks/"+taskID, nil)
-	if err != nil {
-		return nil, err
-	}
+// DeleteTask removes a task and returns its final state.
+func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) {
+	return del(ctx, c, "/tasks", taskID, "task", func(r taskResponse) Task { return r.Task })
+}
 
-	return &resp.Task, nil
+// TaskBuilder constructs and creates a task via method chaining.
+//
+//	task, err := lunatask.NewTask("Review PR").
+//		InArea(areaID).
+//		WithStatus(lunatask.StatusNext).
+//		WithEstimate(30).
+//		Create(ctx, client)
+type TaskBuilder struct {
+	req createTaskRequest
+}
+
+// NewTask starts building a task with the given name.
+func NewTask(name string) *TaskBuilder {
+	return &TaskBuilder{req: createTaskRequest{Name: name}} //nolint:exhaustruct
+}
+
+// InArea assigns the task to an area. IDs are in the area's settings in the app.
+func (b *TaskBuilder) InArea(areaID string) *TaskBuilder {
+	b.req.AreaID = &areaID
+
+	return b
 }
 
-// CreateTask adds a task. Returns (nil, nil) if a duplicate exists
+// InGoal assigns the task to a goal. IDs are in the goal's settings in the app.
+func (b *TaskBuilder) InGoal(goalID string) *TaskBuilder {
+	b.req.GoalID = &goalID
+
+	return b
+}
+
+// WithNote attaches a Markdown note to the task.
+func (b *TaskBuilder) WithNote(note string) *TaskBuilder {
+	b.req.Note = &note
+
+	return b
+}
+
+// WithStatus sets the workflow status.
+// Use one of the Status* constants (e.g., [StatusNext]).
+func (b *TaskBuilder) WithStatus(status TaskStatus) *TaskBuilder {
+	b.req.Status = &status
+
+	return b
+}
+
+// WithMotivation sets why this task matters.
+// Use one of the Motivation* constants (e.g., [MotivationMust]).
+func (b *TaskBuilder) WithMotivation(motivation Motivation) *TaskBuilder {
+	b.req.Motivation = &motivation
+
+	return b
+}
+
+// WithEstimate sets the expected duration in minutes (0–720).
+func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder {
+	b.req.Estimate = &minutes
+
+	return b
+}
+
+// WithPriority sets importance from -2 (lowest) to 2 (highest).
+func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder {
+	b.req.Priority = &priority
+
+	return b
+}
+
+// WithEisenhower sets the matrix quadrant (0–4).
+func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder {
+	b.req.Eisenhower = &eisenhower
+
+	return b
+}
+
+// ScheduledOn sets when the task should appear on your schedule.
+func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder {
+	b.req.ScheduledOn = &date
+
+	return b
+}
+
+// CompletedAt marks the task completed at a specific time.
+func (b *TaskBuilder) CompletedAt(t time.Time) *TaskBuilder {
+	b.req.CompletedAt = &t
+
+	return b
+}
+
+// FromSource tags the task with a free-form origin identifier, useful for
+// tracking tasks created by scripts or external integrations.
+func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder {
+	b.req.Source = &source
+	b.req.SourceID = &sourceID
+
+	return b
+}
+
+// Create sends the task to Lunatask. Returns (nil, nil) if a duplicate exists
 // with matching source/source_id.
-func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task, error) {
-	if task.Name == "" {
+func (b *TaskBuilder) Create(ctx context.Context, c *Client) (*Task, error) {
+	if b.req.Name == "" {
 		return nil, fmt.Errorf("%w: name is required", ErrBadRequest)
 	}
 
-	resp, noContent, err := doJSON[taskResponse](ctx, c, http.MethodPost, "/tasks", task)
-	if err != nil {
-		return nil, err
-	}
+	return create(ctx, c, "/tasks", b.req, func(r taskResponse) Task { return r.Task })
+}
 
-	if noContent {
-		// Intentional: duplicate exists (HTTP 204), not an error
-		return nil, nil //nolint:nilnil
-	}
+// TaskUpdateBuilder constructs and updates a task via method chaining.
+// Only fields you set will be modified; others remain unchanged.
+//
+//	task, err := lunatask.NewTaskUpdate(taskID).
+//		WithStatus(lunatask.StatusCompleted).
+//		CompletedAt(time.Now()).
+//		Update(ctx, client)
+type TaskUpdateBuilder struct {
+	taskID string
+	req    updateTaskRequest
+}
 
-	return &resp.Task, nil
+// NewTaskUpdate starts building a task update for the given task ID.
+func NewTaskUpdate(taskID string) *TaskUpdateBuilder {
+	return &TaskUpdateBuilder{taskID: taskID} //nolint:exhaustruct
 }
 
-// UpdateTask modifies a task. Only non-nil fields in the request are changed.
-func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTaskRequest) (*Task, error) {
-	if taskID == "" {
-		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
-	}
+// Name changes the task's name.
+func (b *TaskUpdateBuilder) Name(name string) *TaskUpdateBuilder {
+	b.req.Name = &name
 
-	resp, _, err := doJSON[taskResponse](ctx, c, http.MethodPut, "/tasks/"+taskID, task)
-	if err != nil {
-		return nil, err
-	}
+	return b
+}
+
+// InArea moves the task to an area. IDs are in the area's settings in the app.
+func (b *TaskUpdateBuilder) InArea(areaID string) *TaskUpdateBuilder {
+	b.req.AreaID = &areaID
 
-	return &resp.Task, nil
+	return b
 }
 
-// DeleteTask removes a task and returns its final state.
-func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) {
-	if taskID == "" {
-		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
-	}
+// InGoal moves the task to a goal. IDs are in the goal's settings in the app.
+func (b *TaskUpdateBuilder) InGoal(goalID string) *TaskUpdateBuilder {
+	b.req.GoalID = &goalID
 
-	resp, _, err := doJSON[taskResponse](ctx, c, http.MethodDelete, "/tasks/"+taskID, nil)
-	if err != nil {
-		return nil, err
-	}
+	return b
+}
+
+// WithNote replaces the task's Markdown note.
+func (b *TaskUpdateBuilder) WithNote(note string) *TaskUpdateBuilder {
+	b.req.Note = &note
+
+	return b
+}
+
+// WithStatus sets the workflow status.
+// Use one of the Status* constants (e.g., [StatusNext]).
+func (b *TaskUpdateBuilder) WithStatus(status TaskStatus) *TaskUpdateBuilder {
+	b.req.Status = &status
+
+	return b
+}
+
+// WithMotivation sets why this task matters.
+// Use one of the Motivation* constants (e.g., [MotivationMust]).
+func (b *TaskUpdateBuilder) WithMotivation(motivation Motivation) *TaskUpdateBuilder {
+	b.req.Motivation = &motivation
+
+	return b
+}
+
+// WithEstimate sets the expected duration in minutes (0–720).
+func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder {
+	b.req.Estimate = &minutes
+
+	return b
+}
+
+// WithPriority sets importance from -2 (lowest) to 2 (highest).
+func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder {
+	b.req.Priority = &priority
+
+	return b
+}
+
+// WithEisenhower sets the matrix quadrant (0–4).
+func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder {
+	b.req.Eisenhower = &eisenhower
+
+	return b
+}
+
+// ScheduledOn sets when the task should appear on your schedule.
+func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder {
+	b.req.ScheduledOn = &date
+
+	return b
+}
+
+// CompletedAt marks the task completed at a specific time.
+func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder {
+	b.req.CompletedAt = &t
+
+	return b
+}
 
-	return &resp.Task, nil
+// Update sends the changes to Lunatask.
+func (b *TaskUpdateBuilder) Update(ctx context.Context, c *Client) (*Task, error) {
+	return update(ctx, c, "/tasks", b.taskID, "task", b.req, func(r taskResponse) Task { return r.Task })
 }

timeline.go 🔗

@@ -20,9 +20,8 @@ type PersonTimelineNote struct {
 	UpdatedAt time.Time `json:"updated_at"`
 }
 
-// CreatePersonTimelineNoteRequest defines a timeline note for a person.
-// Use [TimelineNoteBuilder] for a fluent construction API.
-type CreatePersonTimelineNoteRequest struct {
+// createPersonTimelineNoteRequest defines a timeline note for JSON serialization.
+type createPersonTimelineNoteRequest struct {
 	// PersonID is the ID of the person to add the note to (required).
 	PersonID string `json:"person_id"`
 	// DateOn is the date for the note (optional, defaults to today).
@@ -36,17 +35,45 @@ type personTimelineNoteResponse struct {
 	PersonTimelineNote PersonTimelineNote `json:"person_timeline_note"`
 }
 
-// CreatePersonTimelineNote adds a note to a person's memory timeline.
+// TimelineNoteBuilder constructs and creates a timeline note via method chaining.
+// Content is encrypted client-side; the API accepts it on create but returns null on read.
+//
+//	note, err := lunatask.NewTimelineNote(personID).
+//		OnDate(lunatask.Today()).
+//		WithContent("Had coffee, discussed the project.").
+//		Create(ctx, client)
+type TimelineNoteBuilder struct {
+	req createPersonTimelineNoteRequest
+}
+
+// NewTimelineNote starts building a timeline note for the given person.
+// Get person IDs from [Client.ListPeople].
+func NewTimelineNote(personID string) *TimelineNoteBuilder {
+	return &TimelineNoteBuilder{req: createPersonTimelineNoteRequest{PersonID: personID}} //nolint:exhaustruct
+}
+
+// OnDate sets when this interaction occurred.
+func (b *TimelineNoteBuilder) OnDate(date Date) *TimelineNoteBuilder {
+	b.req.DateOn = &date
+
+	return b
+}
+
+// WithContent sets the Markdown body describing the interaction.
+func (b *TimelineNoteBuilder) WithContent(content string) *TimelineNoteBuilder {
+	b.req.Content = &content
+
+	return b
+}
+
+// Create sends the timeline note to Lunatask.
 // Get person IDs from [Client.ListPeople].
-func (c *Client) CreatePersonTimelineNote(
-	ctx context.Context,
-	note *CreatePersonTimelineNoteRequest,
-) (*PersonTimelineNote, error) {
-	if note.PersonID == "" {
+func (b *TimelineNoteBuilder) Create(ctx context.Context, c *Client) (*PersonTimelineNote, error) {
+	if b.req.PersonID == "" {
 		return nil, fmt.Errorf("%w: person_id is required", ErrBadRequest)
 	}
 
-	resp, _, err := doJSON[personTimelineNoteResponse](ctx, c, http.MethodPost, "/person_timeline_notes", note)
+	resp, _, err := doJSON[personTimelineNoteResponse](ctx, c, http.MethodPost, "/person_timeline_notes", b.req)
 	if err != nil {
 		return nil, err
 	}