diff --git a/api/v1alpha1/agentteam_types.go b/api/v1alpha1/agentteam_types.go
index 6c5dcf2..046e701 100644
--- a/api/v1alpha1/agentteam_types.go
+++ b/api/v1alpha1/agentteam_types.go
@@ -413,10 +413,18 @@ type LifecycleSpec struct {
BudgetLimit *string `json:"budgetLimit,omitempty"`
// OnComplete determines what happens when the team finishes.
- // +kubebuilder:validation:Enum=create-pr;push-branch;notify;none
+ // +kubebuilder:validation:Enum=create-pr;push-branch;notify;deliver;none
// +kubebuilder:default="notify"
OnComplete string `json:"onComplete,omitempty"`
+ // Delivery is the list of artifact delivery targets fired when
+ // OnComplete=deliver. Each target is dispatched independently;
+ // per-target success/failure is recorded in status.delivery[].
+ // Delivery failure is best-effort — the team is not rolled back to
+ // Failed if a target rejects the request.
+ // +optional
+ Delivery []DeliveryTarget `json:"delivery,omitempty"`
+
// PullRequest configures PR creation when onComplete is "create-pr".
// +optional
PullRequest *PullRequestSpec `json:"pullRequest,omitempty"`
@@ -464,6 +472,76 @@ type LifecycleSpec struct {
ConsolidatedBranchTemplate string `json:"consolidatedBranchTemplate,omitempty"`
}
+// DeliveryTarget describes one artifact delivery destination fired when
+// OnComplete=deliver. The Type discriminator selects which fields are
+// meaningful — webhook + slack are functional in v0.8.0; email and
+// google-drive are accepted at the API level and dispatched to senders
+// that currently return a "not implemented" error recorded in
+// status.delivery[].
+//
+// Across all types the operator never persists credentials itself; the
+// sender pulls them from CredentialsSecret at dispatch time so a
+// compromised operator pod can't enumerate Slack tokens / SMTP
+// passwords / Drive service-account keys at rest.
+type DeliveryTarget struct {
+ // Type names the delivery backend.
+ // +kubebuilder:validation:Enum=webhook;slack;email;google-drive
+ Type string `json:"type"`
+
+ // ArtifactPath is the file path within the team's output workspace
+ // (typically /workspace/output) to attach to or send as the
+ // delivery body. Optional for delivery types that carry their
+ // message inline (e.g. a plain Slack notification with no file).
+ // +optional
+ ArtifactPath string `json:"artifactPath,omitempty"`
+
+ // Message is the human-readable text that accompanies the delivery
+ // (Slack message text, webhook notes, email body lead-in).
+ // +optional
+ Message string `json:"message,omitempty"`
+
+ // URL is the destination for the webhook delivery type.
+ // +optional
+ URL string `json:"url,omitempty"`
+
+ // Channel is the destination for the slack delivery type
+ // (e.g. "#reports"). The slack sender reads
+ // CredentialsSecret["slack-webhook-url"] to know where to post.
+ // +optional
+ Channel string `json:"channel,omitempty"`
+
+ // To is the recipient list for the email delivery type.
+ // +optional
+ To []string `json:"to,omitempty"`
+
+ // Subject is the message subject for the email delivery type.
+ // +optional
+ Subject string `json:"subject,omitempty"`
+
+ // AttachmentPath is a file path within the team's output workspace
+ // to attach to the email delivery. Equivalent to ArtifactPath but
+ // kept distinct because some emails attach + reference a separate
+ // artifact in the body.
+ // +optional
+ AttachmentPath string `json:"attachmentPath,omitempty"`
+
+ // Folder is the destination folder for the google-drive delivery
+ // type.
+ // +optional
+ Folder string `json:"folder,omitempty"`
+
+ // CredentialsSecret names a Secret in the team's namespace carrying
+ // authentication for this target. Expected keys per type:
+ //
+ // - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ // - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ // - google-drive: "service-account.json"
+ //
+ // Not required for webhook; the URL is in the spec.
+ // +optional
+ CredentialsSecret string `json:"credentialsSecret,omitempty"`
+}
+
// PullRequestSpec configures automatic PR creation.
type PullRequestSpec struct {
// TargetBranch is the branch to open the PR against.
@@ -594,6 +672,13 @@ type AgentTeamStatus struct {
// +optional
Pipeline *PipelineStatus `json:"pipeline,omitempty"`
+ // Delivery records the outcome of every DeliveryTarget dispatched
+ // by OnComplete=deliver. Populated once executeOnComplete has run;
+ // each entry is independent — partial success is normal and the
+ // team is not rolled back when individual targets fail.
+ // +optional
+ Delivery []DeliveryStatus `json:"delivery,omitempty"`
+
// Conditions represent the latest available observations.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
@@ -633,6 +718,28 @@ type TeammateStatus struct {
RestartCount int32 `json:"restartCount,omitempty"`
}
+// DeliveryStatus records the result of a single DeliveryTarget dispatch.
+type DeliveryStatus struct {
+ // Type mirrors the originating DeliveryTarget.Type.
+ Type string `json:"type"`
+
+ // Target is a short human-readable label describing where this
+ // delivery went (e.g. "#reports" for slack, the URL for webhook).
+ // +optional
+ Target string `json:"target,omitempty"`
+
+ // DeliveredAt is when the sender finished — whether successfully
+ // or not.
+ DeliveredAt metav1.Time `json:"deliveredAt"`
+
+ // Success is true iff the sender returned no error.
+ Success bool `json:"success"`
+
+ // Error carries the sender's failure message when Success is false.
+ // +optional
+ Error string `json:"error,omitempty"`
+}
+
// PipelineStatus reports stage-level progress for a pipelined team.
type PipelineStatus struct {
// CurrentStage names the lowest-indexed stage that has not yet
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 78e8f3d..a94b092 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -415,6 +415,13 @@ func (in *AgentTeamStatus) DeepCopyInto(out *AgentTeamStatus) {
*out = new(PipelineStatus)
(*in).DeepCopyInto(*out)
}
+ if in.Delivery != nil {
+ in, out := &in.Delivery, &out.Delivery
+ *out = make([]DeliveryStatus, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
@@ -757,6 +764,42 @@ func (in *CoordinationSpec) DeepCopy() *CoordinationSpec {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DeliveryStatus) DeepCopyInto(out *DeliveryStatus) {
+ *out = *in
+ in.DeliveredAt.DeepCopyInto(&out.DeliveredAt)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeliveryStatus.
+func (in *DeliveryStatus) DeepCopy() *DeliveryStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(DeliveryStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DeliveryTarget) DeepCopyInto(out *DeliveryTarget) {
+ *out = *in
+ if in.To != nil {
+ in, out := &in.To, &out.To
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeliveryTarget.
+func (in *DeliveryTarget) DeepCopy() *DeliveryTarget {
+ if in == nil {
+ return nil
+ }
+ out := new(DeliveryTarget)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InputSpec) DeepCopyInto(out *InputSpec) {
*out = *in
@@ -806,6 +849,13 @@ func (in *LifecycleSpec) DeepCopyInto(out *LifecycleSpec) {
*out = new(string)
**out = **in
}
+ if in.Delivery != nil {
+ in, out := &in.Delivery, &out.Delivery
+ *out = make([]DeliveryTarget, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
if in.PullRequest != nil {
in, out := &in.PullRequest, &out.PullRequest
*out = new(PullRequestSpec)
diff --git a/charts/kagents/crds/kagents.dev_agentteamruns.yaml b/charts/kagents/crds/kagents.dev_agentteamruns.yaml
index addf5a5..e48869e 100644
--- a/charts/kagents/crds/kagents.dev_agentteamruns.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamruns.yaml
@@ -241,6 +241,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -274,6 +362,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
@@ -509,6 +598,43 @@ spec:
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch.
type: string
+ delivery:
+ description: |-
+ Delivery records the outcome of every DeliveryTarget dispatched
+ by OnComplete=deliver. Populated once executeOnComplete has run;
+ each entry is independent — partial success is normal and the
+ team is not rolled back when individual targets fail.
+ items:
+ description: DeliveryStatus records the result of a single DeliveryTarget
+ dispatch.
+ properties:
+ deliveredAt:
+ description: |-
+ DeliveredAt is when the sender finished — whether successfully
+ or not.
+ format: date-time
+ type: string
+ error:
+ description: Error carries the sender's failure message when
+ Success is false.
+ type: string
+ success:
+ description: Success is true iff the sender returned no error.
+ type: boolean
+ target:
+ description: |-
+ Target is a short human-readable label describing where this
+ delivery went (e.g. "#reports" for slack, the URL for webhook).
+ type: string
+ type:
+ description: Type mirrors the originating DeliveryTarget.Type.
+ type: string
+ required:
+ - deliveredAt
+ - success
+ - type
+ type: object
+ type: array
estimatedCost:
description: EstimatedCost is the estimated API cost in USD (e.g.
"4.50").
diff --git a/charts/kagents/crds/kagents.dev_agentteams.yaml b/charts/kagents/crds/kagents.dev_agentteams.yaml
index 8d4a2e7..5d3f1bd 100644
--- a/charts/kagents/crds/kagents.dev_agentteams.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteams.yaml
@@ -298,6 +298,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -331,6 +419,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
@@ -919,6 +1008,43 @@ spec:
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch.
type: string
+ delivery:
+ description: |-
+ Delivery records the outcome of every DeliveryTarget dispatched
+ by OnComplete=deliver. Populated once executeOnComplete has run;
+ each entry is independent — partial success is normal and the
+ team is not rolled back when individual targets fail.
+ items:
+ description: DeliveryStatus records the result of a single DeliveryTarget
+ dispatch.
+ properties:
+ deliveredAt:
+ description: |-
+ DeliveredAt is when the sender finished — whether successfully
+ or not.
+ format: date-time
+ type: string
+ error:
+ description: Error carries the sender's failure message when
+ Success is false.
+ type: string
+ success:
+ description: Success is true iff the sender returned no error.
+ type: boolean
+ target:
+ description: |-
+ Target is a short human-readable label describing where this
+ delivery went (e.g. "#reports" for slack, the URL for webhook).
+ type: string
+ type:
+ description: Type mirrors the originating DeliveryTarget.Type.
+ type: string
+ required:
+ - deliveredAt
+ - success
+ - type
+ type: object
+ type: array
estimatedCost:
description: EstimatedCost is the estimated API cost in USD (e.g.
"4.50").
diff --git a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
index e23cba7..2ca45e8 100644
--- a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml
@@ -260,6 +260,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -293,6 +381,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
index 12beb8e..49435c8 100644
--- a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml
@@ -134,6 +134,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -167,6 +255,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
index 3834628..4cc54d1 100644
--- a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
+++ b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml
@@ -271,6 +271,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -304,6 +392,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/cmd/manager/main.go b/cmd/manager/main.go
index b161664..fecaebf 100644
--- a/cmd/manager/main.go
+++ b/cmd/manager/main.go
@@ -16,6 +16,7 @@ import (
claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
"github.com/amcheste/kagents/internal/controller"
+ "github.com/amcheste/kagents/internal/delivery"
"github.com/amcheste/kagents/internal/harness"
"github.com/amcheste/kagents/internal/metrics"
)
@@ -76,6 +77,7 @@ func main() {
InitImage: initImage,
SkipInitScript: skipInitScript,
Harnesses: harness.DefaultRegistry(),
+ Delivery: delivery.NewDispatcher(),
}
if agentCommand != "" {
reconciler.AgentCommand = strings.Split(agentCommand, ",")
diff --git a/config/crd/bases/kagents.dev_agentteamruns.yaml b/config/crd/bases/kagents.dev_agentteamruns.yaml
index addf5a5..e48869e 100644
--- a/config/crd/bases/kagents.dev_agentteamruns.yaml
+++ b/config/crd/bases/kagents.dev_agentteamruns.yaml
@@ -241,6 +241,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -274,6 +362,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
@@ -509,6 +598,43 @@ spec:
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch.
type: string
+ delivery:
+ description: |-
+ Delivery records the outcome of every DeliveryTarget dispatched
+ by OnComplete=deliver. Populated once executeOnComplete has run;
+ each entry is independent — partial success is normal and the
+ team is not rolled back when individual targets fail.
+ items:
+ description: DeliveryStatus records the result of a single DeliveryTarget
+ dispatch.
+ properties:
+ deliveredAt:
+ description: |-
+ DeliveredAt is when the sender finished — whether successfully
+ or not.
+ format: date-time
+ type: string
+ error:
+ description: Error carries the sender's failure message when
+ Success is false.
+ type: string
+ success:
+ description: Success is true iff the sender returned no error.
+ type: boolean
+ target:
+ description: |-
+ Target is a short human-readable label describing where this
+ delivery went (e.g. "#reports" for slack, the URL for webhook).
+ type: string
+ type:
+ description: Type mirrors the originating DeliveryTarget.Type.
+ type: string
+ required:
+ - deliveredAt
+ - success
+ - type
+ type: object
+ type: array
estimatedCost:
description: EstimatedCost is the estimated API cost in USD (e.g.
"4.50").
diff --git a/config/crd/bases/kagents.dev_agentteams.yaml b/config/crd/bases/kagents.dev_agentteams.yaml
index 8d4a2e7..5d3f1bd 100644
--- a/config/crd/bases/kagents.dev_agentteams.yaml
+++ b/config/crd/bases/kagents.dev_agentteams.yaml
@@ -298,6 +298,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -331,6 +419,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
@@ -919,6 +1008,43 @@ spec:
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch.
type: string
+ delivery:
+ description: |-
+ Delivery records the outcome of every DeliveryTarget dispatched
+ by OnComplete=deliver. Populated once executeOnComplete has run;
+ each entry is independent — partial success is normal and the
+ team is not rolled back when individual targets fail.
+ items:
+ description: DeliveryStatus records the result of a single DeliveryTarget
+ dispatch.
+ properties:
+ deliveredAt:
+ description: |-
+ DeliveredAt is when the sender finished — whether successfully
+ or not.
+ format: date-time
+ type: string
+ error:
+ description: Error carries the sender's failure message when
+ Success is false.
+ type: string
+ success:
+ description: Success is true iff the sender returned no error.
+ type: boolean
+ target:
+ description: |-
+ Target is a short human-readable label describing where this
+ delivery went (e.g. "#reports" for slack, the URL for webhook).
+ type: string
+ type:
+ description: Type mirrors the originating DeliveryTarget.Type.
+ type: string
+ required:
+ - deliveredAt
+ - success
+ - type
+ type: object
+ type: array
estimatedCost:
description: EstimatedCost is the estimated API cost in USD (e.g.
"4.50").
diff --git a/config/crd/bases/kagents.dev_agentteamschedules.yaml b/config/crd/bases/kagents.dev_agentteamschedules.yaml
index e23cba7..2ca45e8 100644
--- a/config/crd/bases/kagents.dev_agentteamschedules.yaml
+++ b/config/crd/bases/kagents.dev_agentteamschedules.yaml
@@ -260,6 +260,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -293,6 +381,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/config/crd/bases/kagents.dev_agentteamtemplates.yaml b/config/crd/bases/kagents.dev_agentteamtemplates.yaml
index 12beb8e..49435c8 100644
--- a/config/crd/bases/kagents.dev_agentteamtemplates.yaml
+++ b/config/crd/bases/kagents.dev_agentteamtemplates.yaml
@@ -134,6 +134,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -167,6 +255,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/config/crd/bases/kagents.dev_agentteamtriggers.yaml b/config/crd/bases/kagents.dev_agentteamtriggers.yaml
index 3834628..4cc54d1 100644
--- a/config/crd/bases/kagents.dev_agentteamtriggers.yaml
+++ b/config/crd/bases/kagents.dev_agentteamtriggers.yaml
@@ -271,6 +271,94 @@ spec:
branch name pushed by OnComplete=push-branch. Available variables:
.TeamName, .Namespace. When empty, defaults to "teams/{{.TeamName}}".
type: string
+ delivery:
+ description: |-
+ Delivery is the list of artifact delivery targets fired when
+ OnComplete=deliver. Each target is dispatched independently;
+ per-target success/failure is recorded in status.delivery[].
+ Delivery failure is best-effort — the team is not rolled back to
+ Failed if a target rejects the request.
+ items:
+ description: |-
+ DeliveryTarget describes one artifact delivery destination fired when
+ OnComplete=deliver. The Type discriminator selects which fields are
+ meaningful — webhook + slack are functional in v0.8.0; email and
+ google-drive are accepted at the API level and dispatched to senders
+ that currently return a "not implemented" error recorded in
+ status.delivery[].
+
+ Across all types the operator never persists credentials itself; the
+ sender pulls them from CredentialsSecret at dispatch time so a
+ compromised operator pod can't enumerate Slack tokens / SMTP
+ passwords / Drive service-account keys at rest.
+ properties:
+ artifactPath:
+ description: |-
+ ArtifactPath is the file path within the team's output workspace
+ (typically /workspace/output) to attach to or send as the
+ delivery body. Optional for delivery types that carry their
+ message inline (e.g. a plain Slack notification with no file).
+ type: string
+ attachmentPath:
+ description: |-
+ AttachmentPath is a file path within the team's output workspace
+ to attach to the email delivery. Equivalent to ArtifactPath but
+ kept distinct because some emails attach + reference a separate
+ artifact in the body.
+ type: string
+ channel:
+ description: |-
+ Channel is the destination for the slack delivery type
+ (e.g. "#reports"). The slack sender reads
+ CredentialsSecret["slack-webhook-url"] to know where to post.
+ type: string
+ credentialsSecret:
+ description: |-
+ CredentialsSecret names a Secret in the team's namespace carrying
+ authentication for this target. Expected keys per type:
+
+ - slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
+ - email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
+ - google-drive: "service-account.json"
+
+ Not required for webhook; the URL is in the spec.
+ type: string
+ folder:
+ description: |-
+ Folder is the destination folder for the google-drive delivery
+ type.
+ type: string
+ message:
+ description: |-
+ Message is the human-readable text that accompanies the delivery
+ (Slack message text, webhook notes, email body lead-in).
+ type: string
+ subject:
+ description: Subject is the message subject for the email
+ delivery type.
+ type: string
+ to:
+ description: To is the recipient list for the email delivery
+ type.
+ items:
+ type: string
+ type: array
+ type:
+ description: Type names the delivery backend.
+ enum:
+ - webhook
+ - slack
+ - email
+ - google-drive
+ type: string
+ url:
+ description: URL is the destination for the webhook delivery
+ type.
+ type: string
+ required:
+ - type
+ type: object
+ type: array
gitCredentialsSecret:
description: |-
GitCredentialsSecret names a Secret in the team's namespace carrying git
@@ -304,6 +392,7 @@ spec:
- create-pr
- push-branch
- notify
+ - deliver
- none
type: string
prTitleTemplate:
diff --git a/config/samples/delivery-team.yaml b/config/samples/delivery-team.yaml
new file mode 100644
index 0000000..7781340
--- /dev/null
+++ b/config/samples/delivery-team.yaml
@@ -0,0 +1,68 @@
+# Delivery sample — fire notifications when a team completes.
+#
+# When the team reaches Completed phase, the operator iterates each
+# entry in spec.lifecycle.delivery and dispatches it to the matching
+# sender. Per-target success/failure lands in status.delivery; one
+# target failing does not roll the team back to Failed.
+#
+# This sample uses the two MVP backends: webhook (raw HTTP POST) and
+# slack (formatted message via incoming-webhook URL stored in a Secret).
+# The API also defines `email` and `google-drive` types — they parse
+# successfully but their senders return "not implemented" so the team
+# will record a per-target failure if either is used today.
+#
+# Setup before applying:
+# kubectl -n cowork-agents create secret generic slack-creds \
+# --from-literal=slack-webhook-url='https://hooks.slack.com/services/T000/B000/XXX'
+#
+# Apply with:
+# kubectl apply -n cowork-agents -f config/samples/delivery-team.yaml
+apiVersion: kagents.dev/v1alpha1
+kind: AgentTeam
+metadata:
+ name: weekly-digest
+ namespace: cowork-agents
+spec:
+ workspace:
+ output:
+ mountPath: "/workspace/output"
+ size: "1Gi"
+
+ auth:
+ apiKeySecret: "anthropic-api-key"
+
+ lead:
+ model: "opus"
+ prompt: |
+ Produce a one-page weekly status digest summarizing the team's
+ recent activity. Save as /workspace/output/digest.md.
+
+ teammates:
+ - name: "writer"
+ model: "sonnet"
+ prompt: |
+ Write the digest content. When done, save to
+ /workspace/output/digest.md.
+ outputs:
+ - path: "/workspace/output/digest.md"
+ description: "Weekly status digest."
+
+ lifecycle:
+ onComplete: deliver
+ delivery:
+ # Webhook delivery — POSTs a metadata envelope (event, team,
+ # namespace, phase, message, artifacts) as application/json. The
+ # receiver is expected to fetch artifact files separately from
+ # wherever they're persisted; the operator does not stream bytes.
+ - type: webhook
+ url: "https://ops.example.com/hooks/team-completed"
+ message: "Weekly digest finished."
+
+ # Slack delivery — the operator loads slack-webhook-url from the
+ # CredentialsSecret at dispatch time and POSTs a formatted message
+ # to it. Channel is optional and only meaningful for generic
+ # incoming-webhook integrations not already scoped to a channel.
+ - type: slack
+ channel: "#weekly-digest"
+ message: "Fresh digest is in. Artifact list below."
+ credentialsSecret: "slack-creds"
diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md
index 9e2f849..743d5e7 100644
--- a/docs/reference/api/index.md
+++ b/docs/reference/api/index.md
@@ -216,6 +216,7 @@ _Appears in:_
| `consolidatedBranch` _string_ | ConsolidatedBranch is the branch name pushed by OnComplete=push-branch.
Populated once the push-branch Job succeeds; OnComplete=create-pr reads
this as the PR head branch when set, in place of Spec.Repository.Branch. | | Optional: \{\}
|
| `artifacts` _[ArtifactStatus](#artifactstatus) array_ | Artifacts records the files produced by teammates that declared
Outputs in their spec. Populated as each producer teammate reaches
Completed; the operator does not retroactively scan teammate pods
for undeclared files. | | Optional: \{\}
|
| `pipeline` _[PipelineStatus](#pipelinestatus)_ | Pipeline reports stage-level progress when spec.pipeline is set.
Recomputed every reconcile from teammate pod phases; cleared if
spec.pipeline is removed. | | Optional: \{\}
|
+| `delivery` _[DeliveryStatus](#deliverystatus) array_ | Delivery records the outcome of every DeliveryTarget dispatched
by OnComplete=deliver. Populated once executeOnComplete has run;
each entry is independent — partial success is normal and the
team is not rolled back when individual targets fail. | | Optional: \{\}
|
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions represent the latest available observations. | | Optional: \{\}
|
@@ -446,6 +447,61 @@ _Appears in:_
| `beads` _[BeadsSpec](#beadsspec)_ | Beads configures optional Beads integration for persistent tracking. | | Optional: \{\}
|
+#### DeliveryStatus
+
+
+
+DeliveryStatus records the result of a single DeliveryTarget dispatch.
+
+
+
+_Appears in:_
+- [AgentTeamStatus](#agentteamstatus)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `type` _string_ | Type mirrors the originating DeliveryTarget.Type. | | |
+| `target` _string_ | Target is a short human-readable label describing where this
delivery went (e.g. "#reports" for slack, the URL for webhook). | | Optional: \{\}
|
+| `deliveredAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#time-v1-meta)_ | DeliveredAt is when the sender finished — whether successfully
or not. | | |
+| `success` _boolean_ | Success is true iff the sender returned no error. | | |
+| `error` _string_ | Error carries the sender's failure message when Success is false. | | Optional: \{\}
|
+
+
+#### DeliveryTarget
+
+
+
+DeliveryTarget describes one artifact delivery destination fired when
+OnComplete=deliver. The Type discriminator selects which fields are
+meaningful — webhook + slack are functional in v0.8.0; email and
+google-drive are accepted at the API level and dispatched to senders
+that currently return a "not implemented" error recorded in
+status.delivery[].
+
+Across all types the operator never persists credentials itself; the
+sender pulls them from CredentialsSecret at dispatch time so a
+compromised operator pod can't enumerate Slack tokens / SMTP
+passwords / Drive service-account keys at rest.
+
+
+
+_Appears in:_
+- [LifecycleSpec](#lifecyclespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `type` _string_ | Type names the delivery backend. | | Enum: [webhook slack email google-drive]
|
+| `artifactPath` _string_ | ArtifactPath is the file path within the team's output workspace
(typically /workspace/output) to attach to or send as the
delivery body. Optional for delivery types that carry their
message inline (e.g. a plain Slack notification with no file). | | Optional: \{\}
|
+| `message` _string_ | Message is the human-readable text that accompanies the delivery
(Slack message text, webhook notes, email body lead-in). | | Optional: \{\}
|
+| `url` _string_ | URL is the destination for the webhook delivery type. | | Optional: \{\}
|
+| `channel` _string_ | Channel is the destination for the slack delivery type
(e.g. "#reports"). The slack sender reads
CredentialsSecret["slack-webhook-url"] to know where to post. | | Optional: \{\}
|
+| `to` _string array_ | To is the recipient list for the email delivery type. | | Optional: \{\}
|
+| `subject` _string_ | Subject is the message subject for the email delivery type. | | Optional: \{\}
|
+| `attachmentPath` _string_ | AttachmentPath is a file path within the team's output workspace
to attach to the email delivery. Equivalent to ArtifactPath but
kept distinct because some emails attach + reference a separate
artifact in the body. | | Optional: \{\}
|
+| `folder` _string_ | Folder is the destination folder for the google-drive delivery
type. | | Optional: \{\}
|
+| `credentialsSecret` _string_ | CredentialsSecret names a Secret in the team's namespace carrying
authentication for this target. Expected keys per type:
- slack: "slack-webhook-url" — full https://hooks.slack.com/... URL
- email: "smtp-host", "smtp-port", "smtp-username", "smtp-password"
- google-drive: "service-account.json"
Not required for webhook; the URL is in the spec. | | Optional: \{\}
|
+
+
#### InputSpec
@@ -512,7 +568,8 @@ _Appears in:_
| --- | --- | --- | --- |
| `timeout` _string_ | Timeout is the maximum duration the team can run (e.g. "4h", "30m"). | 4h | |
| `budgetLimit` _string_ | BudgetLimit is the maximum API spend in USD before the team is terminated (e.g. "10.00"). | | Optional: \{\}
|
-| `onComplete` _string_ | OnComplete determines what happens when the team finishes. | notify | Enum: [create-pr push-branch notify none]
|
+| `onComplete` _string_ | OnComplete determines what happens when the team finishes. | notify | Enum: [create-pr push-branch notify deliver none]
|
+| `delivery` _[DeliveryTarget](#deliverytarget) array_ | Delivery is the list of artifact delivery targets fired when
OnComplete=deliver. Each target is dispatched independently;
per-target success/failure is recorded in status.delivery[].
Delivery failure is best-effort — the team is not rolled back to
Failed if a target rejects the request. | | Optional: \{\}
|
| `pullRequest` _[PullRequestSpec](#pullrequestspec)_ | PullRequest configures PR creation when onComplete is "create-pr". | | Optional: \{\}
|
| `approvalGates` _[ApprovalGateSpec](#approvalgatespec) array_ | ApprovalGates pause execution before specified events until human approval is recorded.
Grant approval by annotating the AgentTeam: kubectl annotate agentteam approved.kagents.dev/=true | | Optional: \{\}
|
| `maxRestarts` _integer_ | MaxRestarts bounds how many times each teammate pod may be re-spawned
after a Failed phase before the team itself is marked Failed. The lead
pod is not subject to this limit; a lead crash always fails the team. | 3 | Minimum: 0
Optional: \{\}
|
diff --git a/internal/controller/agentteam_controller.go b/internal/controller/agentteam_controller.go
index dbcb0f2..6d353c1 100644
--- a/internal/controller/agentteam_controller.go
+++ b/internal/controller/agentteam_controller.go
@@ -26,6 +26,7 @@ import (
claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
"github.com/amcheste/kagents/internal/budget"
+ "github.com/amcheste/kagents/internal/delivery"
"github.com/amcheste/kagents/internal/github"
"github.com/amcheste/kagents/internal/harness"
"github.com/amcheste/kagents/internal/metrics"
@@ -80,6 +81,23 @@ type AgentTeamReconciler struct {
// Unit tests that construct a reconciler directly may leave this nil;
// harnessFor falls back to a built-in claude-code adapter in that case.
Harnesses map[string]harness.Harness
+
+ // Delivery dispatches DeliveryTarget instances when OnComplete=deliver.
+ // Tests inject a Dispatcher with stub senders; production wires
+ // delivery.NewDispatcher() in cmd/manager. May be nil — a default
+ // production dispatcher is constructed on demand.
+ Delivery *delivery.Dispatcher
+}
+
+// deliveryDispatcher returns the configured Delivery dispatcher or a
+// freshly-built default. Centralizing the fallback keeps unit tests that
+// don't populate Delivery working and gives misconfigured reconcilers a
+// safe default rather than a nil-deref.
+func (r *AgentTeamReconciler) deliveryDispatcher() *delivery.Dispatcher {
+ if r.Delivery != nil {
+ return r.Delivery
+ }
+ return delivery.NewDispatcher()
}
// harnessFor returns the [harness.Harness] adapter that should drive the
@@ -1607,10 +1625,49 @@ func (r *AgentTeamReconciler) executeOnComplete(ctx context.Context, team *claud
} else {
log.Info("push-branch: consolidated branch pushed", "branch", team.Status.ConsolidatedBranch)
}
+ case "deliver":
+ r.executeDelivery(ctx, team)
}
return nil
}
+// executeDelivery dispatches each DeliveryTarget in spec.lifecycle.delivery
+// to its registered sender, recording a DeliveryStatus entry per target
+// on team.Status.Delivery regardless of outcome. Failures are surfaced
+// as events + status but never return as errors — the design's stance
+// is that delivery is best-effort and a partial delivery (e.g. Slack
+// posted, email broke) shouldn't fail the team retroactively.
+//
+// Idempotent at the executeOnComplete level: the caller only invokes
+// this once per team's lifecycle (when transitioning to a terminal
+// phase). The function itself doesn't guard against re-invocation —
+// the reconciler's transition logic is the gate.
+func (r *AgentTeamReconciler) executeDelivery(ctx context.Context, team *claudev1alpha1.AgentTeam) {
+ if team.Spec.Lifecycle == nil || len(team.Spec.Lifecycle.Delivery) == 0 {
+ return
+ }
+ dispatcher := r.deliveryDispatcher()
+ now := time.Now()
+ for _, target := range team.Spec.Lifecycle.Delivery {
+ status := claudev1alpha1.DeliveryStatus{
+ Type: target.Type,
+ Target: delivery.TargetLabel(target),
+ DeliveredAt: metav1.NewTime(now),
+ Success: true,
+ }
+ if err := dispatcher.Send(ctx, r.Client, target, team); err != nil {
+ status.Success = false
+ status.Error = err.Error()
+ r.recordEvent(team, corev1.EventTypeWarning, "DeliveryFailed",
+ "Delivery %s to %s failed: %v", target.Type, status.Target, err)
+ } else {
+ r.recordEvent(team, corev1.EventTypeNormal, "DeliveryComplete",
+ "Delivery %s to %s succeeded", target.Type, status.Target)
+ }
+ team.Status.Delivery = append(team.Status.Delivery, status)
+ }
+}
+
// --- Pull Request Creation ---
const (
diff --git a/internal/controller/agentteam_delivery_test.go b/internal/controller/agentteam_delivery_test.go
new file mode 100644
index 0000000..c925f6f
--- /dev/null
+++ b/internal/controller/agentteam_delivery_test.go
@@ -0,0 +1,144 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+ "github.com/amcheste/kagents/internal/delivery"
+)
+
+// deliverySchemeForTests is a local scheme so this file's tests stay
+// independent of helpers in the larger test suite.
+func deliverySchemeForTests(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ s := runtime.NewScheme()
+ require.NoError(t, clientgoscheme.AddToScheme(s))
+ require.NoError(t, claudev1alpha1.AddToScheme(s))
+ return s
+}
+
+// TestExecuteDelivery_RecordsStatusPerTarget exercises the full
+// reconciler-side delivery path with a stub dispatcher. The point is
+// to prove the operator records exactly one DeliveryStatus per target,
+// captures the per-type label, and surfaces success/error correctly —
+// without coupling the test to the real HTTP senders (those are
+// covered in the delivery package's own tests).
+func TestExecuteDelivery_RecordsStatusPerTarget(t *testing.T) {
+ t.Parallel()
+ scheme := deliverySchemeForTests(t)
+ team := &claudev1alpha1.AgentTeam{
+ ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"},
+ Spec: claudev1alpha1.AgentTeamSpec{
+ Lifecycle: &claudev1alpha1.LifecycleSpec{
+ OnComplete: "deliver",
+ Delivery: []claudev1alpha1.DeliveryTarget{
+ {Type: "webhook", URL: "https://ops.example.com/hook"},
+ {Type: "slack", Channel: "#reports", CredentialsSecret: "creds"},
+ },
+ },
+ },
+ Status: claudev1alpha1.AgentTeamStatus{Phase: "Completed"},
+ }
+
+ d := delivery.NewDispatcher()
+ d.SetSender("webhook", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error {
+ return nil
+ }))
+ d.SetSender("slack", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error {
+ return errors.New("boom")
+ }))
+
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build()
+ r := &AgentTeamReconciler{Client: c, Scheme: scheme, Delivery: d}
+
+ r.executeDelivery(context.Background(), team)
+
+ require.Len(t, team.Status.Delivery, 2)
+ assert.Equal(t, "webhook", team.Status.Delivery[0].Type)
+ assert.Equal(t, "https://ops.example.com/hook", team.Status.Delivery[0].Target)
+ assert.True(t, team.Status.Delivery[0].Success)
+ assert.Empty(t, team.Status.Delivery[0].Error)
+ assert.False(t, team.Status.Delivery[0].DeliveredAt.IsZero())
+
+ assert.Equal(t, "slack", team.Status.Delivery[1].Type)
+ assert.Equal(t, "#reports", team.Status.Delivery[1].Target)
+ assert.False(t, team.Status.Delivery[1].Success)
+ assert.Contains(t, team.Status.Delivery[1].Error, "boom")
+}
+
+// TestExecuteDelivery_NoOpOnEmpty verifies the function returns
+// cleanly when no targets are configured — the OnComplete=deliver enum
+// is allowed without targets (e.g. user-typed CR with empty array)
+// and we don't want a stray panic or status mutation in that case.
+func TestExecuteDelivery_NoOpOnEmpty(t *testing.T) {
+ t.Parallel()
+ scheme := deliverySchemeForTests(t)
+ team := &claudev1alpha1.AgentTeam{
+ ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"},
+ Spec: claudev1alpha1.AgentTeamSpec{
+ Lifecycle: &claudev1alpha1.LifecycleSpec{OnComplete: "deliver"},
+ },
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build()
+ r := &AgentTeamReconciler{Client: c, Scheme: scheme}
+ r.executeDelivery(context.Background(), team)
+ assert.Empty(t, team.Status.Delivery)
+}
+
+// TestExecuteDelivery_NilLifecycle defends against a programmer error
+// — the OnComplete branch in executeOnComplete is unreachable if
+// Lifecycle is nil, but we still want belt-and-suspenders here so a
+// future refactor that exposes executeDelivery from elsewhere doesn't
+// silently crash.
+func TestExecuteDelivery_NilLifecycle(t *testing.T) {
+ t.Parallel()
+ scheme := deliverySchemeForTests(t)
+ team := &claudev1alpha1.AgentTeam{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build()
+ r := &AgentTeamReconciler{Client: c, Scheme: scheme}
+ r.executeDelivery(context.Background(), team)
+ assert.Empty(t, team.Status.Delivery)
+}
+
+// TestExecuteOnComplete_DispatchesDeliver wires the higher-level entry
+// point — executeOnComplete with OnComplete="deliver" — to confirm the
+// switch case routes to executeDelivery. This is the only test that
+// crosses the case boundary; everything else exercises executeDelivery
+// directly so failure messages stay focused.
+func TestExecuteOnComplete_DispatchesDeliver(t *testing.T) {
+ t.Parallel()
+ scheme := deliverySchemeForTests(t)
+ team := &claudev1alpha1.AgentTeam{
+ ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"},
+ Spec: claudev1alpha1.AgentTeamSpec{
+ Lifecycle: &claudev1alpha1.LifecycleSpec{
+ OnComplete: "deliver",
+ Delivery: []claudev1alpha1.DeliveryTarget{
+ {Type: "webhook", URL: "https://x"},
+ },
+ },
+ },
+ }
+ d := delivery.NewDispatcher()
+ called := false
+ d.SetSender("webhook", delivery.SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error {
+ called = true
+ return nil
+ }))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team).Build()
+ r := &AgentTeamReconciler{Client: c, Scheme: scheme, Delivery: d}
+
+ require.NoError(t, r.executeOnComplete(context.Background(), team))
+ assert.True(t, called, "executeOnComplete should route OnComplete=deliver to executeDelivery")
+ require.Len(t, team.Status.Delivery, 1)
+}
diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go
new file mode 100644
index 0000000..a47426a
--- /dev/null
+++ b/internal/delivery/delivery.go
@@ -0,0 +1,119 @@
+// Package delivery dispatches team-completion notifications to external
+// systems (webhook, Slack, email, Google Drive) when an AgentTeam ends
+// with OnComplete=deliver.
+//
+// The senders implemented here run in-process from the reconciler — fast,
+// simple HTTPS requests with metadata about the completed team. They do
+// NOT stream artifact file contents: the operator pod doesn't mount the
+// team's output PVC, so transferring files end-to-end would require a
+// Job pattern (designed for, but deferred). The MVP delivers notifications
+// referencing the team's artifacts; downstream systems are expected to
+// fetch the actual files from their persisted location.
+//
+// New backends register in NewDispatcher() — keep the surface small
+// (Send is the only interface method) so adding one is contained.
+package delivery
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+)
+
+// ErrNotImplemented is returned by senders whose backend is declared in
+// the API but not yet wired. Status records it as a per-target failure
+// rather than failing the whole team — this is exactly the partial-
+// success path the design doc calls out.
+var ErrNotImplemented = errors.New("delivery type not yet implemented")
+
+// Sender dispatches a single DeliveryTarget. Implementations should
+// return nil on success and a descriptive error on failure; the
+// reconciler records the failure in status.delivery[] and does not
+// surface it as a team-level failure.
+type Sender interface {
+ Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error
+}
+
+// SenderFunc adapts a plain function to the Sender interface — useful
+// for tests that want to inject a captured-call recorder without
+// declaring a new type.
+type SenderFunc func(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error
+
+// Send implements Sender.
+func (f SenderFunc) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error {
+ return f(ctx, c, target, team)
+}
+
+// Dispatcher selects the right Sender for a given DeliveryTarget.Type.
+// One Dispatcher is shared across the reconciler for the lifetime of
+// the operator; tests construct their own with stub senders.
+type Dispatcher struct {
+ senders map[string]Sender
+}
+
+// NewDispatcher returns a Dispatcher wired with the production senders.
+// Email and Google Drive are present at the API level but currently
+// return ErrNotImplemented — operators see a per-target failure in
+// status.delivery rather than a runtime panic.
+func NewDispatcher() *Dispatcher {
+ return &Dispatcher{
+ senders: map[string]Sender{
+ "webhook": &WebhookSender{},
+ "slack": &SlackSender{},
+ "email": notImplemented("email"),
+ "google-drive": notImplemented("google-drive"),
+ },
+ }
+}
+
+// SetSender overrides the sender for a single type. Tests use this to
+// inject a recorder without rebuilding the whole dispatcher.
+func (d *Dispatcher) SetSender(typ string, s Sender) {
+ if d.senders == nil {
+ d.senders = make(map[string]Sender)
+ }
+ d.senders[typ] = s
+}
+
+// Send dispatches the target to its matching Sender. Returns
+// ErrNotImplemented when no sender is registered for the type, so the
+// reconciler can record a clean failure rather than silently dropping.
+func (d *Dispatcher) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error {
+ s, ok := d.senders[target.Type]
+ if !ok {
+ return fmt.Errorf("%w: %q", ErrNotImplemented, target.Type)
+ }
+ return s.Send(ctx, c, target, team)
+}
+
+// TargetLabel produces a short human-readable description for status
+// reporting (rendered into DeliveryStatus.Target). Backend-specific:
+// the URL for webhook, the channel for slack, the To list for email,
+// the folder for drive.
+func TargetLabel(t claudev1alpha1.DeliveryTarget) string {
+ switch t.Type {
+ case "webhook":
+ return t.URL
+ case "slack":
+ return t.Channel
+ case "email":
+ return strings.Join(t.To, ", ")
+ case "google-drive":
+ return t.Folder
+ }
+ return ""
+}
+
+// notImplemented returns a Sender that always fails with ErrNotImplemented
+// wrapped to include the type name. Used as the registry entry for
+// declared-but-unimplemented backends.
+func notImplemented(typ string) Sender {
+ return SenderFunc(func(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error {
+ return fmt.Errorf("%w: %s", ErrNotImplemented, typ)
+ })
+}
diff --git a/internal/delivery/delivery_test.go b/internal/delivery/delivery_test.go
new file mode 100644
index 0000000..31a1386
--- /dev/null
+++ b/internal/delivery/delivery_test.go
@@ -0,0 +1,247 @@
+package delivery
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+)
+
+func newScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ s := runtime.NewScheme()
+ require.NoError(t, clientgoscheme.AddToScheme(s))
+ require.NoError(t, claudev1alpha1.AddToScheme(s))
+ return s
+}
+
+func sampleTeam() *claudev1alpha1.AgentTeam {
+ return &claudev1alpha1.AgentTeam{
+ ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"},
+ Status: claudev1alpha1.AgentTeamStatus{
+ Phase: "Completed",
+ Artifacts: []claudev1alpha1.ArtifactStatus{
+ {Name: "report.pdf", ProducedBy: "writer"},
+ },
+ },
+ }
+}
+
+// --- Dispatcher ---
+
+func TestDispatcher_RoutesByType(t *testing.T) {
+ t.Parallel()
+ d := NewDispatcher()
+
+ called := false
+ d.SetSender("webhook", SenderFunc(func(_ context.Context, _ client.Client, _ claudev1alpha1.DeliveryTarget, _ *claudev1alpha1.AgentTeam) error {
+ called = true
+ return nil
+ }))
+ require.NoError(t, d.Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "webhook"}, sampleTeam()))
+ assert.True(t, called)
+}
+
+func TestDispatcher_UnknownTypeReturnsNotImplemented(t *testing.T) {
+ t.Parallel()
+ d := NewDispatcher()
+ err := d.Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "carrier-pigeon"}, sampleTeam())
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrNotImplemented)
+}
+
+func TestDispatcher_EmailReturnsNotImplemented(t *testing.T) {
+ t.Parallel()
+ d := NewDispatcher()
+ err := d.Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "email"}, sampleTeam())
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrNotImplemented)
+}
+
+func TestDispatcher_GoogleDriveReturnsNotImplemented(t *testing.T) {
+ t.Parallel()
+ d := NewDispatcher()
+ err := d.Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "google-drive"}, sampleTeam())
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrNotImplemented)
+}
+
+func TestTargetLabel(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ name string
+ in claudev1alpha1.DeliveryTarget
+ want string
+ }{
+ {"webhook", claudev1alpha1.DeliveryTarget{Type: "webhook", URL: "https://x"}, "https://x"},
+ {"slack", claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#reports"}, "#reports"},
+ {"email", claudev1alpha1.DeliveryTarget{Type: "email", To: []string{"a@b", "c@d"}}, "a@b, c@d"},
+ {"google-drive", claudev1alpha1.DeliveryTarget{Type: "google-drive", Folder: "Reports/Q3"}, "Reports/Q3"},
+ {"unknown", claudev1alpha1.DeliveryTarget{Type: "?"}, ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, TargetLabel(tc.in))
+ })
+ }
+}
+
+// --- WebhookSender ---
+
+func TestWebhookSender_PostsPayload(t *testing.T) {
+ t.Parallel()
+ var capturedBody []byte
+ var capturedCT string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedCT = r.Header.Get("Content-Type")
+ capturedBody, _ = io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ sender := &WebhookSender{}
+ target := claudev1alpha1.DeliveryTarget{Type: "webhook", URL: srv.URL, Message: "Q3 brief done."}
+ require.NoError(t, sender.Send(context.Background(), nil, target, sampleTeam()))
+
+ assert.Equal(t, "application/json", capturedCT)
+ var got webhookPayload
+ require.NoError(t, json.Unmarshal(capturedBody, &got))
+ assert.Equal(t, "team.completed", got.Event)
+ assert.Equal(t, "alpha", got.Team)
+ assert.Equal(t, "ns", got.Namespace)
+ assert.Equal(t, "Completed", got.Phase)
+ assert.Equal(t, "Q3 brief done.", got.Message)
+ require.Len(t, got.Artifacts, 1)
+ assert.Equal(t, "report.pdf", got.Artifacts[0].Name)
+}
+
+func TestWebhookSender_Non2xxIsFailure(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ sender := &WebhookSender{}
+ err := sender.Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "webhook", URL: srv.URL}, sampleTeam())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "500")
+}
+
+func TestWebhookSender_NoURLFails(t *testing.T) {
+ t.Parallel()
+ err := (&WebhookSender{}).Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "webhook"}, sampleTeam())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "url")
+}
+
+// --- SlackSender ---
+
+func TestSlackSender_PostsToWebhookURLFromSecret(t *testing.T) {
+ t.Parallel()
+ var capturedBody []byte
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedBody, _ = io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "slack-creds", Namespace: "ns"},
+ Data: map[string][]byte{"slack-webhook-url": []byte(srv.URL)},
+ }
+ c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build()
+ sender := &SlackSender{}
+ target := claudev1alpha1.DeliveryTarget{
+ Type: "slack", Channel: "#reports", Message: "hello", CredentialsSecret: "slack-creds",
+ }
+ require.NoError(t, sender.Send(context.Background(), c, target, sampleTeam()))
+
+ var got slackPayload
+ require.NoError(t, json.Unmarshal(capturedBody, &got))
+ assert.Equal(t, "#reports", got.Channel)
+ assert.Contains(t, got.Text, "hello")
+ assert.Contains(t, got.Text, "report.pdf", "artifact list appears in the message body")
+}
+
+func TestSlackSender_MissingSecretFails(t *testing.T) {
+ t.Parallel()
+ c := fake.NewClientBuilder().WithScheme(newScheme(t)).Build()
+ err := (&SlackSender{}).Send(context.Background(), c,
+ claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x"}, sampleTeam())
+ require.Error(t, err)
+}
+
+func TestSlackSender_MissingWebhookURLKeyFails(t *testing.T) {
+ t.Parallel()
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "ns"},
+ Data: map[string][]byte{"wrong-key": []byte("x")},
+ }
+ c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build()
+ err := (&SlackSender{}).Send(context.Background(), c,
+ claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x", CredentialsSecret: "bad"}, sampleTeam())
+ require.Error(t, err)
+}
+
+func TestSlackSender_Non2xxIsFailure(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer srv.Close()
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"},
+ Data: map[string][]byte{"slack-webhook-url": []byte(srv.URL)},
+ }
+ c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(secret).Build()
+ err := (&SlackSender{}).Send(context.Background(), c,
+ claudev1alpha1.DeliveryTarget{Type: "slack", Channel: "#x", CredentialsSecret: "creds"}, sampleTeam())
+ require.Error(t, err)
+}
+
+func TestFormatSlackMessage_DefaultWhenMessageEmpty(t *testing.T) {
+ t.Parallel()
+ team := sampleTeam()
+ team.Status.Artifacts = nil
+ msg := formatSlackMessage(claudev1alpha1.DeliveryTarget{Type: "slack"}, team)
+ assert.Contains(t, msg, "alpha")
+ assert.Contains(t, msg, "Completed")
+}
+
+func TestFormatSlackMessage_IncludesArtifactList(t *testing.T) {
+ t.Parallel()
+ msg := formatSlackMessage(claudev1alpha1.DeliveryTarget{Message: "custom"}, sampleTeam())
+ assert.Contains(t, msg, "custom")
+ assert.Contains(t, msg, "report.pdf")
+ assert.Contains(t, msg, "writer")
+}
+
+// --- ErrNotImplemented sentinel ---
+
+func TestErrNotImplemented_IsCheckable(t *testing.T) {
+ t.Parallel()
+ err := (&Dispatcher{}).Send(context.Background(), nil,
+ claudev1alpha1.DeliveryTarget{Type: "missing"}, sampleTeam())
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrNotImplemented))
+}
diff --git a/internal/delivery/slack.go b/internal/delivery/slack.go
new file mode 100644
index 0000000..83b0585
--- /dev/null
+++ b/internal/delivery/slack.go
@@ -0,0 +1,101 @@
+package delivery
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+)
+
+// SlackSender posts a formatted notification to Slack via a webhook URL
+// the operator pulls from the team's CredentialsSecret at dispatch time
+// (key: "slack-webhook-url"). The operator never sees the webhook URL
+// at rest — it's loaded only when a delivery fires.
+type SlackSender struct {
+ // Client is the HTTP client used. Tests inject a server-rooted
+ // client; production leaves it nil and a default is used.
+ Client *http.Client
+}
+
+// slackPayload is the minimum Slack webhook payload shape: text +
+// optional channel override. Slack ignores channel when the webhook URL
+// is already channel-scoped; including it is harmless and helps when
+// the workspace uses a generic incoming-webhook integration.
+type slackPayload struct {
+ Text string `json:"text"`
+ Channel string `json:"channel,omitempty"`
+}
+
+// Send fetches the Slack webhook URL from CredentialsSecret and POSTs
+// the formatted message. A non-2xx response is treated as failure.
+func (s *SlackSender) Send(ctx context.Context, c client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error {
+ if target.CredentialsSecret == "" {
+ return fmt.Errorf("slack delivery requires credentialsSecret")
+ }
+
+ var secret corev1.Secret
+ if err := c.Get(ctx, types.NamespacedName{Name: target.CredentialsSecret, Namespace: team.Namespace}, &secret); err != nil {
+ return fmt.Errorf("loading credentials %s: %w", target.CredentialsSecret, err)
+ }
+ webhookURL := string(secret.Data["slack-webhook-url"])
+ if webhookURL == "" {
+ return fmt.Errorf("secret %s is missing key %q", target.CredentialsSecret, "slack-webhook-url")
+ }
+
+ body, err := json.Marshal(slackPayload{
+ Text: formatSlackMessage(target, team),
+ Channel: target.Channel,
+ })
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+
+ httpClient := s.Client
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "kagents/0.8")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("post: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("slack returned %s", resp.Status)
+ }
+ return nil
+}
+
+// formatSlackMessage builds a one-line summary plus an artifact list.
+// Slack ignores Markdown in text by default; the asterisks below render
+// as bold in mrkdwn-mode webhooks and as literal characters otherwise.
+func formatSlackMessage(target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) string {
+ msg := target.Message
+ if msg == "" {
+ msg = fmt.Sprintf("Team *%s* finished with phase *%s*.", team.Name, team.Status.Phase)
+ }
+ if len(team.Status.Artifacts) == 0 {
+ return msg
+ }
+ buf := bytes.NewBufferString(msg)
+ buf.WriteString("\nArtifacts:\n")
+ for _, a := range team.Status.Artifacts {
+ fmt.Fprintf(buf, "• %s (by %s)\n", a.Name, a.ProducedBy)
+ }
+ return buf.String()
+}
diff --git a/internal/delivery/webhook.go b/internal/delivery/webhook.go
new file mode 100644
index 0000000..25aebec
--- /dev/null
+++ b/internal/delivery/webhook.go
@@ -0,0 +1,78 @@
+package delivery
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1"
+)
+
+// WebhookSender POSTs a JSON metadata envelope to a configured URL.
+// The payload is intentionally small — team identity, artifacts, the
+// message — because the operator pod doesn't read artifact file bytes
+// (see internal/delivery/delivery.go package doc). Downstream systems
+// that want file contents are expected to fetch from persisted storage.
+type WebhookSender struct {
+ // Client is the HTTP client used. Tests inject a server-rooted
+ // client; production leaves it nil and a default is used.
+ Client *http.Client
+}
+
+// webhookPayload is the JSON shape the webhook receiver gets. Fields
+// are intentionally stable (and snake_case for cross-language consumers)
+// so downstream automation can rely on this contract.
+type webhookPayload struct {
+ Event string `json:"event"`
+ Team string `json:"team"`
+ Namespace string `json:"namespace"`
+ Phase string `json:"phase"`
+ Message string `json:"message,omitempty"`
+ Artifacts []claudev1alpha1.ArtifactStatus `json:"artifacts,omitempty"`
+}
+
+// Send POSTs the payload as application/json. A non-2xx response is
+// treated as failure and surfaces in status.delivery[].
+func (w *WebhookSender) Send(ctx context.Context, _ client.Client, target claudev1alpha1.DeliveryTarget, team *claudev1alpha1.AgentTeam) error {
+ if target.URL == "" {
+ return fmt.Errorf("webhook delivery requires url")
+ }
+ body, err := json.Marshal(webhookPayload{
+ Event: "team.completed",
+ Team: team.Name,
+ Namespace: team.Namespace,
+ Phase: team.Status.Phase,
+ Message: target.Message,
+ Artifacts: team.Status.Artifacts,
+ })
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+
+ httpClient := w.Client
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.URL, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "kagents/0.8")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("post: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("webhook returned %s", resp.Status)
+ }
+ return nil
+}